Compare commits
157 Commits
f88ae2d491
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b431802d50 | ||
|
|
5559e9951a | ||
|
|
9c7a383ee2 | ||
|
|
496f9d432d | ||
|
|
9a7e0d6818 | ||
|
|
6afd0c0392 | ||
|
|
c741e520ea | ||
|
|
ed3a3f0571 | ||
|
|
20dde11ff0 | ||
|
|
4857438e6f | ||
|
|
252c2447a8 | ||
|
|
a7c732e139 | ||
|
|
302b55b346 | ||
|
|
e8eafaf9b9 | ||
|
|
5aa1be27bb | ||
|
|
5c9ab6bb11 | ||
|
|
800eb976ab | ||
|
|
c07938c94e | ||
|
|
615e07ad32 | ||
|
|
c07264fd69 | ||
|
|
9c77475ce5 | ||
|
|
eaa0a5010d | ||
|
|
a6e07e46d9 | ||
|
|
166f97c629 | ||
|
|
98d278947b | ||
|
|
c7536d9097 | ||
|
|
b4e51eb8eb | ||
|
|
51b73ed82b | ||
|
|
eb5cc01d56 | ||
|
|
4c3df482be | ||
|
|
377754c26e | ||
|
|
bc74a1089b | ||
|
|
f992517891 | ||
|
|
6283f86091 | ||
|
|
e1f001b57d | ||
|
|
88089343a8 | ||
|
|
9af5f296a7 | ||
|
|
3066ba06a2 | ||
|
|
6608957574 | ||
|
|
1dcc565299 | ||
|
|
0cae89277f | ||
|
|
e332374b2d | ||
|
|
e62ec6052e | ||
|
|
4bfd0d4591 | ||
|
|
897d4cbe66 | ||
|
|
8eeadf00ae | ||
|
|
422828f343 | ||
|
|
5e81a8f2c3 | ||
|
|
f92348958b | ||
|
|
ba9cc3ad01 | ||
|
|
948931886e | ||
|
|
63efb47e77 | ||
|
|
bc05fb4504 | ||
|
|
060b4da907 | ||
|
|
8f3f2f3e93 | ||
|
|
afaa18ca14 | ||
|
|
5b6a362bbd | ||
|
|
8320f2ae35 | ||
|
|
1bf5e518de | ||
|
|
d3ea2048cb | ||
|
|
c68d3c7ed4 | ||
|
|
2b1bb8eda9 | ||
|
|
4d659d5eb6 | ||
|
|
fcb7eaf51d | ||
|
|
be1924bc94 | ||
|
|
3928717293 | ||
|
|
3bea89a8ac | ||
|
|
c7808702fa | ||
|
|
d3d8886a90 | ||
|
|
634cf4db48 | ||
|
|
423442578f | ||
|
|
34a75e64f6 | ||
|
|
0b883dd8c2 | ||
|
|
7668b83de6 | ||
|
|
090fa21ba5 | ||
|
|
2ff426559a | ||
|
|
248acee2b8 | ||
|
|
d3ddc17381 | ||
|
|
c736dbe873 | ||
|
|
2dcef87229 | ||
|
|
d512f8b00d | ||
|
|
d1220fdd3f | ||
|
|
f451c3df63 | ||
|
|
fedb1c88a8 | ||
|
|
c881dd7dde | ||
|
|
18d2b6e407 | ||
|
|
5a5b9a035f | ||
|
|
ae7ac964f7 | ||
|
|
566b032fb3 | ||
|
|
022ed007ff | ||
|
|
f2e14bb063 | ||
|
|
c5cc682509 | ||
|
|
56cff262ab | ||
|
|
e0c71de3eb | ||
|
|
76de11f4de | ||
|
|
578835d9da | ||
|
|
dae0b227c1 | ||
|
|
a73b776020 | ||
|
|
67116d13c4 | ||
|
|
8a9f47f569 | ||
|
|
b53ca39fe9 | ||
|
|
caea53b94d | ||
|
|
1b5a8556ba | ||
|
|
67357b591d | ||
|
|
c738b804af | ||
|
|
27d2980b32 | ||
|
|
57fd48ccc5 | ||
|
|
e2b2163c83 | ||
|
|
f5e2de9a35 | ||
|
|
a26bc8813e | ||
|
|
b99b60b32e | ||
|
|
ae7736019f | ||
|
|
6a414a5bf4 | ||
|
|
839e1f814e | ||
|
|
1e511f35a9 | ||
|
|
d66aa77f4d | ||
|
|
fc6f417cc6 | ||
|
|
9bd595fcac | ||
|
|
ff55d0618e | ||
|
|
a0a1108f3a | ||
|
|
8b3ac60883 | ||
|
|
b08bdc4f41 | ||
|
|
285587bda5 | ||
|
|
901752ebc5 | ||
|
|
59fffa2018 | ||
|
|
8c788b66ac | ||
|
|
3d25bc5179 | ||
|
|
789a9304a0 | ||
|
|
af01eba4a7 | ||
|
|
eb77dd3ba2 | ||
|
|
a6858fe2fc | ||
|
|
4131bf9eb8 | ||
|
|
ef27cff046 | ||
|
|
ca9615a1fd | ||
|
|
b129307bae | ||
|
|
b40f6fa23f | ||
|
|
aeb801c0ba | ||
|
|
b7a2059070 | ||
|
|
06faadab5f | ||
|
|
7d60073757 | ||
|
|
335d231903 | ||
|
|
3524405712 | ||
|
|
62e0239ee4 | ||
|
|
3cd4bfc426 | ||
|
|
bd98348989 | ||
|
|
3568e48ce9 | ||
|
|
0874238fc6 | ||
|
|
3c74fa7210 | ||
|
|
9953dd3149 | ||
|
|
c35f8f8a51 | ||
|
|
1af624aa77 | ||
|
|
ca90a6e94b | ||
|
|
2445272f3a | ||
|
|
7764933fd9 | ||
|
|
3a907b0743 | ||
|
|
10c69457ba | ||
|
|
aedfedca76 |
@@ -20,6 +20,15 @@ ADMIN_EMAIL=admin@admin.com
|
||||
ADMIN_PASSWORD=admin123
|
||||
JWT_SECRET=super_secret_jwt_key_123
|
||||
|
||||
# --- CAPTCHA / Bot Protection (Optional) ---
|
||||
# Create keys in Cloudflare Turnstile and set both values in production.
|
||||
# When TURNSTILE_SECRET is empty, backend CAPTCHA enforcement is disabled.
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET=
|
||||
# Backward-compatible aliases also accepted by the backend:
|
||||
# VITE_TURNSTILE_SITE_KEY=
|
||||
# TURNSTILE_SECRET_KEY=
|
||||
|
||||
# --- Frontend Configuration (Optional) ---
|
||||
# If you need to override the API URL for the frontend
|
||||
# VITE_API_URL=/api
|
||||
|
||||
102
CONTEXT.md
102
CONTEXT.md
@@ -36,6 +36,8 @@ This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a rea
|
||||
* **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 Future Operating Layer:** Tiny ERP is the current source for sales, product/order metadata, and stock. Graphs should increasingly become the place where the business manages production intelligence: cut configuration, SKU families, raw material relationships, consumption references, and operational charts. `NECESSIDADE DE CORTE.xlsx` is reference material only and must not become a runtime dependency.
|
||||
|
||||
## 4. Directory Structure
|
||||
```text
|
||||
@@ -63,7 +65,9 @@ This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a rea
|
||||
```
|
||||
|
||||
## 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`.
|
||||
* **Order Entity (`orders` table):** Tracks `cliente_nome`, `data_pedido`, normalized `data_pedido_date`, `valor_pedido`, `produto_id`, `quantidade`, `valor_unitario`, `pedido_id`, and `cliente_fone`. Tiny ERP metadata is also persisted when present: `cliente_nome_fantasia`, `id_vendedor`, `nome_vendedor`, `marketplace`, `canal_venda`, and `numero_ecommerce`.
|
||||
* **Order Metadata Preservation:** Order upserts preserve existing non-empty phone and Tiny metadata values when incoming backfill rows send `NULL` or empty strings. Core mutable order/item fields still overwrite normally.
|
||||
* **Client Identity Rule:** Analytics groups clients by a stable normalized identity so adding a phone number later does not split an existing buyer into a duplicate client. When a phone is added after historical purchases without phone, the old and new rows should resolve to the same client profile and history.
|
||||
* **Stock Entity (`stock` table):** Tracks `produto_id`, `nome`, `saldo` (absolute current inventory), and `delta_estoque`. The database treats the ERP's `saldo` as the Absolute Truth (overwriting existing values rather than performing math) to prevent desynchronization.
|
||||
* **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`.
|
||||
@@ -73,14 +77,57 @@ This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a rea
|
||||
* **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. CI/CD & Deployment
|
||||
## 6. Supply, Cutting, and Data Flow Requirements
|
||||
Tiny provides sales and stock facts, but it does not fully describe how a finished SKU consumes raw material. For Suprimentos and Corte to work reliably, Graphs needs internal domain data that complements Tiny.
|
||||
|
||||
**Current operating assumption:**
|
||||
* Tiny sends orders, product IDs/names, seller/order metadata, and current stock.
|
||||
* Graphs derives demand, sales velocity, replenishment pressure, and cut/material needs from Tiny data plus local configuration.
|
||||
* Excel files are examples of current manual management only. They should guide UX/data modeling but not be imported as the long-term source of truth.
|
||||
|
||||
**Core missing relationship:**
|
||||
```text
|
||||
Finished SKU -> raw material -> consumption/yield
|
||||
```
|
||||
|
||||
Example:
|
||||
```text
|
||||
BASE LISA CAMISETA PRETA G
|
||||
uses: MALHA 100% ALGODAO PRETA
|
||||
yield: 4.8 units/kg
|
||||
```
|
||||
|
||||
**Data Graphs must own or enrich locally:**
|
||||
* **Finished SKU catalog:** SKU/id, name, color, size, product family, cut family, active/inactive.
|
||||
* **Raw material catalog:** material SKU/id, name, type (`malha`, `ribana`, `fio`, etc.), color, supplier, unit (`kg`, `metro`, `unidade`), and whether it is raw input instead of finished stock.
|
||||
* **Consumption references:** finished SKU, material product, yield per kg, optional yield by size, optional color/family overrides, and secondary materials when needed.
|
||||
* **Cut family mapping:** rules that say which colors/sizes/products can be planned together for cutting.
|
||||
* **Open production/cutting data:** quantities already in production, expected finish date, linked finished SKU, and linked raw material when available.
|
||||
* **Business rules:** target coverage days, minimum stock, safety stock, purchase lead time, production lead time, and discontinued/ignored SKU rules.
|
||||
|
||||
**Current UI/data-flow behavior:**
|
||||
* `Suprimentos > Necessidade de Compra` derives material pressure from demand and stock, but rows without a consumption reference are marked as missing reference instead of pretending to calculate kg.
|
||||
* Missing-reference rows link into `Cadastros > Referência de Consumo` with SKU context. If the SKU does not exist in the local catalog yet, `Cadastros` opens the product form first.
|
||||
* `Plano de Corte` SKU edit actions deep-link into the cutting configuration drawer for that SKU.
|
||||
* Product tables and group detail tables are tuned for high-volume values, including large `Total Vendido` quantities above 100k.
|
||||
|
||||
## 7. CI/CD & Deployment
|
||||
* **Gitea Actions:** A workflow located in `.gitea/workflows/deploy.yml` triggers on pushes to the `main` branch.
|
||||
* **Docker Registry:** The pipeline builds the `frontend` and `backend` Docker images and pushes them directly to `gitea.blyzer.com.br/blyzer/`.
|
||||
* **Production Deployment:** Updates are deployed manually via Portainer by pulling the `latest` image tags from the Gitea registry and redeploying the stack.
|
||||
* **Environment Variables:** Security secrets (`API_KEY`, `JWT_SECRET`, `POSTGRES_PASSWORD`, `N8N_WHATSAPP_TRIGGER_URL`) are injected via the Portainer stack configuration and passed into containers via `docker-compose.yml`.
|
||||
|
||||
## 7. Environment Setup & Scripts
|
||||
## 8. Environment Setup & Scripts
|
||||
|
||||
**Running Locally:**
|
||||
1. Start the database:
|
||||
@@ -110,13 +157,18 @@ 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.
|
||||
|
||||
## 8. Coding Standards & AI Directives
|
||||
## 9. Coding Standards & AI Directives
|
||||
* **Strict Type Safety:** Use explicit TypeScript interfaces (defined in `types.ts`). Avoid `any` where possible. Do not bypass type checks with `// @ts-ignore`.
|
||||
* **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`.
|
||||
@@ -124,3 +176,45 @@ The local Docker services use `restart: unless-stopped`, so containers should co
|
||||
* **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.
|
||||
|
||||
## 10. Recent Work
|
||||
Recent commits related to RFV/date and client metadata behavior:
|
||||
* `4131bf9 Update RFV lifecycle to monthly windows` - RFV recency/lost thresholds changed to `0-7`, `8-15`, `16-29`, and `30+` lost.
|
||||
* `ef27cff Add seller performance dashboard charts` - dashboard now includes seller revenue/order charts.
|
||||
* `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:
|
||||
* `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.
|
||||
|
||||
@@ -11,4 +11,4 @@ FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
28
README.md
28
README.md
@@ -52,6 +52,21 @@ The scheduled endpoint is API-key protected and returns a summary:
|
||||
}
|
||||
```
|
||||
|
||||
### 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:
|
||||
@@ -78,11 +93,11 @@ npm run dev
|
||||
Default local URLs:
|
||||
|
||||
```text
|
||||
Frontend: http://127.0.0.1:3001
|
||||
Frontend: http://127.0.0.1:3002
|
||||
Backend: http://127.0.0.1:3004
|
||||
```
|
||||
|
||||
Vite may choose a different frontend port if `3001` is already in use.
|
||||
Vite may choose a different frontend port if `3002` is already in use.
|
||||
|
||||
## Environment
|
||||
|
||||
@@ -97,8 +112,17 @@ N8N_WHATSAPP_TRIGGER_URL
|
||||
ADMIN_EMAIL
|
||||
ADMIN_PASSWORD
|
||||
JWT_SECRET
|
||||
TURNSTILE_SITE_KEY
|
||||
TURNSTILE_SECRET
|
||||
```
|
||||
|
||||
`TURNSTILE_SECRET` enables backend CAPTCHA enforcement on `/api/login`. Set
|
||||
`TURNSTILE_SITE_KEY` with Cloudflare's public site key so the login page can load
|
||||
the verification widget at runtime.
|
||||
|
||||
The backend also accepts `VITE_TURNSTILE_SITE_KEY` as a site-key alias and
|
||||
`TURNSTILE_SECRET_KEY` as a secret alias for older deployments.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const TURNSTILE_KEY_MATCH = /[0-9]x[0-9A-Za-z_-]{20,}/;
|
||||
|
||||
const normalizeTurnstileValue = (value) => {
|
||||
if (typeof value !== 'string') return '';
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
const keyMatch = trimmedValue.match(TURNSTILE_KEY_MATCH);
|
||||
if (keyMatch) return keyMatch[0];
|
||||
|
||||
return trimmedValue.replace(/^[\s"'`{[]+|[\s"'`}\]]+$/g, '');
|
||||
};
|
||||
|
||||
const firstEnvValue = (...values) => values
|
||||
.map(normalizeTurnstileValue)
|
||||
.find(Boolean) || '';
|
||||
|
||||
const TURNSTILE_SITE_KEY = firstEnvValue(
|
||||
process.env.TURNSTILE_SITE_KEY,
|
||||
process.env.TURNSTILE_SITEKEY,
|
||||
process.env.VITE_TURNSTILE_SITE_KEY,
|
||||
process.env.CLOUDFLARE_TURNSTILE_SITE_KEY
|
||||
);
|
||||
|
||||
const TURNSTILE_SECRET = firstEnvValue(
|
||||
process.env.TURNSTILE_SECRET,
|
||||
process.env.TURNSTILE_SECRET_KEY,
|
||||
process.env.CLOUDFLARE_TURNSTILE_SECRET
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
PORT: process.env.PORT || 3004,
|
||||
API_KEY: process.env.API_KEY || 'nexstar_secret_key_123',
|
||||
@@ -7,5 +36,7 @@ module.exports = {
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123',
|
||||
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
|
||||
DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
|
||||
N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp'
|
||||
N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp',
|
||||
TURNSTILE_SITE_KEY,
|
||||
TURNSTILE_SECRET
|
||||
};
|
||||
|
||||
446
backend/db.js
446
backend/db.js
@@ -5,6 +5,29 @@ const pool = new Pool({
|
||||
connectionString: DATABASE_URL
|
||||
});
|
||||
|
||||
const defaultCatalogCategories = [
|
||||
['Camiseta regular', 'Bases lisas e camisetas adultas.'],
|
||||
['Camiseta infantil', 'Produtos infantis por cor e tamanho.'],
|
||||
['Moletom', 'Moletons, cangurus e produtos de frio.'],
|
||||
['Oversized', 'Modelagens oversized e variações relacionadas.'],
|
||||
['Acessórios', 'Bonés, itens complementares e produtos não têxteis.'],
|
||||
['DTF', 'Insumos e serviços relacionados a impressão DTF.'],
|
||||
['Malha', 'Tecidos e malhas usados como matéria-prima.'],
|
||||
['Aviamentos', 'Ribanas, linhas, ilhós e componentes de costura.'],
|
||||
['Embalagens', 'Sacos, etiquetas, tags e materiais de expedição.'],
|
||||
['Insumos gerais', 'Materiais de apoio sem família operacional específica.']
|
||||
];
|
||||
|
||||
const seedDefaultCatalogCategories = async () => {
|
||||
for (const [name, description] of defaultCatalogCategories) {
|
||||
await pool.query(`
|
||||
INSERT INTO catalog_categories (name, description, updated_at)
|
||||
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
`, [name, description]);
|
||||
}
|
||||
};
|
||||
|
||||
const initDB = async () => {
|
||||
try {
|
||||
await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`);
|
||||
@@ -27,6 +50,12 @@ const initDB = async () => {
|
||||
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
|
||||
@@ -85,6 +114,360 @@ const initDB = async () => {
|
||||
);
|
||||
`);
|
||||
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
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 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 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 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
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_orders
|
||||
ADD COLUMN IF NOT EXISTS notes TEXT,
|
||||
ADD COLUMN IF NOT EXISTS supplier TEXT,
|
||||
ADD COLUMN IF NOT EXISTS lot_code VARCHAR(120),
|
||||
ADD COLUMN IF NOT EXISTS roll_quantity NUMERIC(14, 4),
|
||||
ADD COLUMN IF NOT EXISTS fabric_kg NUMERIC(14, 4),
|
||||
ADD COLUMN IF NOT EXISTS rib_kg NUMERIC(14, 4),
|
||||
ADD COLUMN IF NOT EXISTS yield_pieces_per_kg NUMERIC(14, 4);
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_orders
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_order_components
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_order_steps
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
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,
|
||||
@@ -97,6 +480,15 @@ const initDB = async () => {
|
||||
);
|
||||
`);
|
||||
|
||||
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',
|
||||
@@ -139,7 +531,61 @@ const initDB = async () => {
|
||||
});
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_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_order_markers_order_id ON production_order_markers (production_order_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_order_id ON production_order_components (production_order_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_sku ON production_order_components (component_sku);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_steps_order_id ON production_order_steps (production_order_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_product_compositions_finished_tiny_id ON product_compositions (finished_tiny_product_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_product_composition_components_tiny_id ON product_composition_components (component_tiny_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_type ON catalog_products (type);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_product_id ON consumption_references (product_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`);
|
||||
await pool.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS unique_consumption_reference_source
|
||||
ON consumption_references (
|
||||
product_id,
|
||||
COALESCE(material_product_id, 0),
|
||||
COALESCE(color, ''),
|
||||
COALESCE(source, 'manual')
|
||||
);
|
||||
`).catch(err => {
|
||||
console.error('Notice: Could not create unique consumption reference source index:', err.message);
|
||||
});
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_status ON supply_receipts (status);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_created_at ON supply_receipts (created_at DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_stock_lots_status ON supply_stock_lots (status);`);
|
||||
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));`);
|
||||
|
||||
@@ -8,7 +8,13 @@ const formatOrderRow = (row) => ({
|
||||
Valor_Unitario: parseFloat(row.valor_unitario),
|
||||
Recebido_Em: row.created_at,
|
||||
ID_Pedido: row.pedido_id,
|
||||
Fone_Cliente: row.cliente_fone
|
||||
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) => {
|
||||
@@ -35,6 +41,17 @@ const normalizeOrderDate = (dateValue) => {
|
||||
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;
|
||||
@@ -51,7 +68,13 @@ const normalizeOrderPayload = (item) => {
|
||||
parseInt(item.Quantidade, 10) || 0,
|
||||
parseFloat(item.Valor_Unitario) || 0,
|
||||
String(orderId),
|
||||
String(fone)
|
||||
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']))
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -2,10 +2,15 @@ const express = require('express');
|
||||
const { verifyToken } = require('../auth');
|
||||
const {
|
||||
getClientAnalytics,
|
||||
getClientDetailsAnalytics,
|
||||
getClientFilterOptions,
|
||||
getClientPurchasePatternAnalytics,
|
||||
getDashboardAnalytics,
|
||||
getProductAnalytics,
|
||||
getProductDetailsAnalytics,
|
||||
getRfmAnalytics
|
||||
} = require('../services/analyticsService');
|
||||
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -14,6 +19,13 @@ const getRange = (query) => ({
|
||||
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)));
|
||||
@@ -32,18 +44,85 @@ router.get('/analytics/products', verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
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.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(getRange(req.query)));
|
||||
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(getRange(req.query)));
|
||||
res.json(await getRfmAnalytics(getClientAnalyticsFilters(req.query)));
|
||||
} catch (error) {
|
||||
console.error('Error fetching RFM analytics:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
|
||||
@@ -1,12 +1,59 @@
|
||||
const express = require('express');
|
||||
const { login } = require('../auth');
|
||||
const { TURNSTILE_SECRET, TURNSTILE_SITE_KEY } = require('../config');
|
||||
|
||||
const router = express.Router();
|
||||
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
const TURNSTILE_SITE_KEY_PATTERN = /^[0-9]x[0-9A-Za-z_-]{20,}$/;
|
||||
const hasValidTurnstileSiteKey = TURNSTILE_SITE_KEY_PATTERN.test(TURNSTILE_SITE_KEY);
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
const { email, password } = req.body;
|
||||
const verifyCaptcha = async (captchaToken, remoteIp) => {
|
||||
if (!TURNSTILE_SECRET) return true;
|
||||
if (!captchaToken || typeof captchaToken !== 'string') return false;
|
||||
|
||||
try {
|
||||
const formData = new URLSearchParams({
|
||||
secret: TURNSTILE_SECRET,
|
||||
response: captchaToken
|
||||
});
|
||||
|
||||
if (remoteIp) {
|
||||
formData.set('remoteip', remoteIp);
|
||||
}
|
||||
|
||||
const response = await fetch(TURNSTILE_VERIFY_URL, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) return false;
|
||||
|
||||
const result = await response.json();
|
||||
return result.success === true;
|
||||
} catch (error) {
|
||||
console.error('Captcha verification failed', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
router.get('/login/config', (req, res) => {
|
||||
res.json({
|
||||
captchaRequired: Boolean(TURNSTILE_SECRET),
|
||||
turnstileSiteKey: hasValidTurnstileSiteKey ? TURNSTILE_SITE_KEY : '',
|
||||
captchaConfigured: Boolean(TURNSTILE_SECRET && hasValidTurnstileSiteKey)
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
const { email, password, captchaToken } = req.body;
|
||||
|
||||
try {
|
||||
const captchaValid = await verifyCaptcha(captchaToken, req.ip);
|
||||
if (!captchaValid) {
|
||||
res.status(403).json({ error: 'Captcha verification failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const authResult = await login(email, password);
|
||||
|
||||
if (!authResult) {
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
const express = require('express');
|
||||
const { verifyToken } = require('../auth');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const {
|
||||
getCampaignPreview,
|
||||
getCampaignQueueSummary,
|
||||
getTopClientsForCampaign,
|
||||
processPendingStockCampaigns,
|
||||
retryCampaignItems
|
||||
} = require('../services/campaignService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const verifyCampaignExportAccess = (req, res, next) => {
|
||||
if (req.headers['x-api-key']) {
|
||||
authenticateAPIKey(req, res, next);
|
||||
return;
|
||||
}
|
||||
|
||||
verifyToken(req, res, next);
|
||||
};
|
||||
|
||||
router.get('/campaigns', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getCampaignQueueSummary());
|
||||
@@ -27,6 +37,15 @@ router.get('/campaigns/preview', verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/campaigns/top-clients', verifyCampaignExportAccess, async (req, res) => {
|
||||
try {
|
||||
res.json(await getTopClientsForCampaign(req.query || {}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching top campaign clients:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/campaigns/process', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await processPendingStockCampaigns());
|
||||
|
||||
101
backend/routes/catalogRoutes.js
Normal file
101
backend/routes/catalogRoutes.js
Normal file
@@ -0,0 +1,101 @@
|
||||
const express = require('express');
|
||||
const { verifyToken } = require('../auth');
|
||||
const {
|
||||
createCategory,
|
||||
createConsumptionReference,
|
||||
createProduct,
|
||||
deleteCategory,
|
||||
deleteConsumptionReference,
|
||||
deleteProduct,
|
||||
getCatalogSummary,
|
||||
listCategories,
|
||||
listConsumptionReferences,
|
||||
listProducts
|
||||
} = require('../services/catalogService');
|
||||
|
||||
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.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.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.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;
|
||||
23
backend/routes/cuttingSettingsRoutes.js
Normal file
23
backend/routes/cuttingSettingsRoutes.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
15
backend/routes/databaseDiagnosticRoutes.js
Normal file
15
backend/routes/databaseDiagnosticRoutes.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const { verifySuperAdmin } = require('../auth');
|
||||
const { buildDatabaseDiagnostic } = require('../services/databaseDiagnosticService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/admin/database-diagnostic', verifySuperAdmin, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await buildDatabaseDiagnostic(req.user));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
44
backend/routes/productionOrderRoutes.js
Normal file
44
backend/routes/productionOrderRoutes.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const express = require('express');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const { createProductionOrders, listProductionOrders, updateProductionOrderStatus, 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 : [];
|
||||
res.status(201).json(await createProductionOrders(orders));
|
||||
} catch (error) {
|
||||
console.error('Error creating production orders:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => {
|
||||
try {
|
||||
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));
|
||||
} catch (error) {
|
||||
console.error('Error syncing Tiny production order:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/production-orders/:id/status', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await updateProductionOrderStatus(req.params.id, req.body?.status));
|
||||
} catch (error) {
|
||||
console.error('Error updating production order status:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
127
backend/routes/supplyRoutes.js
Normal file
127
backend/routes/supplyRoutes.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const express = require('express');
|
||||
const { verifyToken } = require('../auth');
|
||||
const {
|
||||
adjustInventoryLot,
|
||||
approveReceipt,
|
||||
consumeLotForProduction,
|
||||
createFabricPlan,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
getSupplySummary,
|
||||
listFabricPlans,
|
||||
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/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/purchase-needs', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await listPurchaseNeeds());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
519
backend/scripts/normalize_cut_workbook.py
Normal file
519
backend/scripts/normalize_cut_workbook.py
Normal file
@@ -0,0 +1,519 @@
|
||||
#!/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"}
|
||||
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())
|
||||
@@ -8,6 +8,11 @@ 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 databaseDiagnosticRoutes = require('./routes/databaseDiagnosticRoutes');
|
||||
|
||||
const createApp = () => {
|
||||
const app = express();
|
||||
@@ -19,6 +24,11 @@ const createApp = () => {
|
||||
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', databaseDiagnosticRoutes);
|
||||
app.use('/api', analyticsRoutes);
|
||||
app.use('/api', userRoutes);
|
||||
app.use('/api/internal', internalRoutes);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,26 @@ const applyProductDisplayAlias = (name) => {
|
||||
.replace(/^BASE LISA MOLETOM CANGURU\b/i, 'MOLETOM CANGURU PREMIUM');
|
||||
};
|
||||
|
||||
const normalizeCampaignProductName = (name) => String(name || '')
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
const isCampaignEligibleProductName = (name) => {
|
||||
const normalizedName = normalizeCampaignProductName(name);
|
||||
if (!normalizedName) return false;
|
||||
|
||||
if (/^(?:\d+(?:\.\d+)?\s+)?(?:MALHA|RIBANA)\b/.test(normalizedName)) return false;
|
||||
if (/\b(?:TINTA|FILME|POLIAMIDA|PO PARA DTF|ROLO DTF|FLUIDO|PRIMER|CABECA|SENSOR|FILTRO|BOMBA)\b.*\bDTF\b/.test(normalizedName)) return false;
|
||||
if (/\b(?:MALHA|RIBANA|ATACADOR|ILHOS|ETIQUETA|TAG|FITA|LINHA PARA COSTURA|FIO|TECIDO|RETALHO|RESIDUO)\b/.test(normalizedName)) return false;
|
||||
if (/\b(?:SALDO ESTOQUE|FRETE|SERVICO|TRANSPORTE|TECELAGEM|TINTURARIA)\b/.test(normalizedName)) return false;
|
||||
if (/\b(?:PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA|CABO FLAT|WIPPER|PRIMER|FLUIDO|SENSOR|FILTRO)\b/.test(normalizedName)) return false;
|
||||
|
||||
return /\b(?:DTF|CAMISETA|CAMISA|MOLETOM|CANGURU|REGATA|OVERSIZE|OVER SIZE)\b/.test(normalizedName);
|
||||
};
|
||||
|
||||
const formatProductNameForDisplay = (name) => {
|
||||
return applyProductDisplayAlias(name)
|
||||
.toLocaleLowerCase('pt-BR')
|
||||
@@ -103,5 +123,6 @@ module.exports = {
|
||||
formatProductList,
|
||||
groupCampaignRows,
|
||||
groupCampaignRowsByBaseProduct,
|
||||
isCampaignEligibleProductName,
|
||||
mapCampaignProducts
|
||||
};
|
||||
|
||||
@@ -5,14 +5,118 @@ const {
|
||||
formatProductList,
|
||||
groupCampaignRows,
|
||||
groupCampaignRowsByBaseProduct,
|
||||
isCampaignEligibleProductName,
|
||||
mapCampaignProducts
|
||||
} = require('./campaignFormatter');
|
||||
|
||||
const TOP_BUYERS_LIMIT = 100;
|
||||
const TOP_CLIENTS_DEFAULT_DAYS = 30;
|
||||
const TOP_CLIENTS_DEFAULT_LIMIT = 1000;
|
||||
const TOP_CLIENTS_MAX_LIMIT = 5000;
|
||||
const MAX_CAMPAIGN_ATTEMPTS = 3;
|
||||
const CAMPAIGN_DELTA_THRESHOLD = 100;
|
||||
const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo';
|
||||
const NORMALIZED_CUSTOMER_NAME_SQL = "NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')";
|
||||
const NORMALIZED_CUSTOMER_PHONE_SQL = "NULLIF(regexp_replace(COALESCE(cliente_fone, ''), '\\D', '', 'g'), '')";
|
||||
const WHATSAPP_CUSTOMER_PHONE_SQL = `
|
||||
CASE
|
||||
WHEN ${NORMALIZED_CUSTOMER_PHONE_SQL} LIKE '55%' THEN ${NORMALIZED_CUSTOMER_PHONE_SQL}
|
||||
WHEN length(${NORMALIZED_CUSTOMER_PHONE_SQL}) IN (10, 11) THEN '55' || ${NORMALIZED_CUSTOMER_PHONE_SQL}
|
||||
ELSE ${NORMALIZED_CUSTOMER_PHONE_SQL}
|
||||
END
|
||||
`;
|
||||
const CUSTOMER_IDENTITY_CTE = `
|
||||
WITH customer_phone_by_name AS (
|
||||
SELECT
|
||||
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
|
||||
(ARRAY_AGG(NULLIF(cliente_fone, '') ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
|
||||
)[1] as canonical_phone
|
||||
FROM orders
|
||||
WHERE NULLIF(cliente_fone, '') IS NOT NULL
|
||||
AND ${NORMALIZED_CUSTOMER_NAME_SQL} IS NOT NULL
|
||||
GROUP BY normalized_customer_name
|
||||
),
|
||||
identity_orders AS (
|
||||
SELECT
|
||||
orders.*,
|
||||
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
|
||||
COALESCE(
|
||||
NULLIF(orders.cliente_fone, ''),
|
||||
customer_phone_by_name.canonical_phone,
|
||||
'name:' || COALESCE(NULLIF(orders.cliente_nome, ''), 'Cliente Desconhecido')
|
||||
) as customer_key
|
||||
FROM orders
|
||||
LEFT JOIN customer_phone_by_name
|
||||
ON customer_phone_by_name.normalized_customer_name = ${NORMALIZED_CUSTOMER_NAME_SQL}
|
||||
)
|
||||
`;
|
||||
|
||||
const normalizeDateParam = (value) => {
|
||||
if (!value) return null;
|
||||
|
||||
const match = String(value).trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, yearValue, monthValue, dayValue] = match;
|
||||
const year = Number(yearValue);
|
||||
const month = Number(monthValue);
|
||||
const day = Number(dayValue);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
|
||||
if (
|
||||
date.getUTCFullYear() !== year ||
|
||||
date.getUTCMonth() !== month - 1 ||
|
||||
date.getUTCDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${yearValue}-${monthValue}-${dayValue}`;
|
||||
};
|
||||
|
||||
const parsePositiveInteger = (value, defaultValue, maxValue) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) return defaultValue;
|
||||
return Math.min(parsed, maxValue);
|
||||
};
|
||||
|
||||
const getDateStringInTimeZone = (date = new Date(), timeZone = SAO_PAULO_TIME_ZONE) => {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).formatToParts(date);
|
||||
const partMap = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
||||
|
||||
return `${partMap.year}-${partMap.month}-${partMap.day}`;
|
||||
};
|
||||
|
||||
const subtractDaysFromDateString = (dateString, daysToSubtract) => {
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
date.setUTCDate(date.getUTCDate() - daysToSubtract);
|
||||
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const getTopClientsDateRange = ({ days = TOP_CLIENTS_DEFAULT_DAYS, start, end } = {}) => {
|
||||
const normalizedDays = parsePositiveInteger(days, TOP_CLIENTS_DEFAULT_DAYS, 3650);
|
||||
const normalizedEnd = normalizeDateParam(end) || getDateStringInTimeZone();
|
||||
const normalizedStart = normalizeDateParam(start) || subtractDaysFromDateString(normalizedEnd, normalizedDays - 1);
|
||||
|
||||
return {
|
||||
days: normalizedDays,
|
||||
start: normalizedStart,
|
||||
end: normalizedEnd
|
||||
};
|
||||
};
|
||||
|
||||
const enqueueStockCampaignItem = async (client, item) => {
|
||||
if (!isCampaignEligibleProductName(item.baseProductName || item.nome)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO stock_campaign_queue (
|
||||
base_product_name, produto_id, nome, saldo, delta_estoque
|
||||
@@ -26,6 +130,8 @@ const enqueueStockCampaignItem = async (client, item) => {
|
||||
item.saldo,
|
||||
item.deltaEstoque
|
||||
]);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getTopBuyersAllTime = async () => {
|
||||
@@ -46,6 +152,63 @@ const getTopBuyersAllTime = async () => {
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const getTopClientsForCampaign = async ({ days, limit, start, end } = {}) => {
|
||||
const range = getTopClientsDateRange({ days, start, end });
|
||||
const normalizedLimit = parsePositiveInteger(limit, TOP_CLIENTS_DEFAULT_LIMIT, TOP_CLIENTS_MAX_LIMIT);
|
||||
const result = await pool.query(`
|
||||
WITH campaign_orders AS (
|
||||
SELECT
|
||||
orders.*,
|
||||
${WHATSAPP_CUSTOMER_PHONE_SQL} as whatsapp_phone
|
||||
FROM orders
|
||||
)
|
||||
SELECT
|
||||
(
|
||||
ARRAY_AGG(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')
|
||||
ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
|
||||
)[1] as nome,
|
||||
(
|
||||
ARRAY_AGG(whatsapp_phone ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
|
||||
FILTER (WHERE whatsapp_phone IS NOT NULL)
|
||||
)[1] as fone,
|
||||
COALESCE(SUM(quantidade * valor_unitario), 0) as total_gasto,
|
||||
COALESCE(SUM(quantidade), 0) as total_comprado,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as total_pedidos,
|
||||
MAX(data_pedido_date) as ultima_compra,
|
||||
ARRAY_REMOVE(ARRAY_AGG(DISTINCT whatsapp_phone), NULL) as telefones
|
||||
FROM campaign_orders
|
||||
WHERE data_pedido_date >= $1::date
|
||||
AND data_pedido_date <= $2::date
|
||||
AND whatsapp_phone IS NOT NULL
|
||||
-- A campaign recipient is a WhatsApp destination, so each normalized
|
||||
-- phone number must produce exactly one exported customer.
|
||||
GROUP BY whatsapp_phone
|
||||
ORDER BY total_gasto DESC
|
||||
LIMIT $3;
|
||||
`, [range.start, range.end, normalizedLimit]);
|
||||
|
||||
const customers = result.rows.map(row => ({
|
||||
nome: row.nome,
|
||||
fone: row.fone,
|
||||
total_gasto: Number(row.total_gasto || 0),
|
||||
total_comprado: Number(row.total_comprado || 0),
|
||||
total_pedidos: Number(row.total_pedidos || 0),
|
||||
ultima_compra: row.ultima_compra,
|
||||
telefones: Array.isArray(row.telefones) ? row.telefones : []
|
||||
}));
|
||||
|
||||
return {
|
||||
campaign: 'top_clients',
|
||||
days: range.days,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
limit: normalizedLimit,
|
||||
count: customers.length,
|
||||
generated_at: new Date().toISOString(),
|
||||
customers
|
||||
};
|
||||
};
|
||||
|
||||
const claimReadyCampaignItems = async () => {
|
||||
const client = await pool.connect();
|
||||
|
||||
@@ -117,7 +280,8 @@ const getCampaignQueueRows = async () => {
|
||||
};
|
||||
|
||||
const getCampaignQueueSummary = async () => {
|
||||
const rows = await getCampaignQueueRows();
|
||||
const rows = (await getCampaignQueueRows())
|
||||
.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
|
||||
return {
|
||||
threshold: CAMPAIGN_DELTA_THRESHOLD,
|
||||
maxAttempts: MAX_CAMPAIGN_ATTEMPTS,
|
||||
@@ -134,7 +298,8 @@ const getCampaignPreview = async () => {
|
||||
AND attempts < $1
|
||||
ORDER BY created_at ASC, id ASC;
|
||||
`, [MAX_CAMPAIGN_ATTEMPTS]);
|
||||
const groups = groupCampaignRowsByBaseProduct(result.rows);
|
||||
const eligibleRows = result.rows.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
|
||||
const groups = groupCampaignRowsByBaseProduct(eligibleRows);
|
||||
const readyGroups = {};
|
||||
const belowThresholdGroups = {};
|
||||
|
||||
@@ -205,6 +370,15 @@ const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => {
|
||||
`, [status, errorMessage, ids]);
|
||||
};
|
||||
|
||||
const skipIneligibleCampaignItems = async (rows) => {
|
||||
const ineligibleRows = rows.filter(row => !isCampaignEligibleProductName(row.base_product_name || row.nome));
|
||||
const ids = ineligibleRows.map(row => row.id);
|
||||
|
||||
await updateCampaignItemsStatus(ids, 'skipped', 'Produto não elegível para campanha de cliente.');
|
||||
|
||||
return new Set(ineligibleRows.map(row => row.base_product_name)).size;
|
||||
};
|
||||
|
||||
const sendWhatsappCampaign = async (products, customers) => {
|
||||
const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, {
|
||||
method: 'POST',
|
||||
@@ -231,14 +405,21 @@ const processPendingStockCampaigns = async () => {
|
||||
return summary;
|
||||
}
|
||||
|
||||
const groups = groupCampaignRowsByBaseProduct(rows);
|
||||
summary.skippedGroups += await skipIneligibleCampaignItems(rows);
|
||||
|
||||
const eligibleRows = rows.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
|
||||
const groups = groupCampaignRowsByBaseProduct(eligibleRows);
|
||||
const products = mapCampaignProducts(groups);
|
||||
const ids = products.flatMap(product => product.itemIds);
|
||||
const customers = await getTopBuyersAllTime();
|
||||
|
||||
if (!products.length) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
if (!customers.length) {
|
||||
await updateCampaignItemsStatus(ids, 'skipped', 'No customers with valid phone numbers found.');
|
||||
summary.skippedGroups = products.length;
|
||||
summary.skippedGroups += products.length;
|
||||
return summary;
|
||||
}
|
||||
|
||||
@@ -260,6 +441,7 @@ module.exports = {
|
||||
enqueueStockCampaignItem,
|
||||
getCampaignPreview,
|
||||
getCampaignQueueSummary,
|
||||
getTopClientsForCampaign,
|
||||
retryCampaignItems,
|
||||
processPendingStockCampaigns
|
||||
};
|
||||
|
||||
276
backend/services/catalogService.js
Normal file
276
backend/services/catalogService.js
Normal file
@@ -0,0 +1,276 @@
|
||||
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 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 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 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
|
||||
};
|
||||
157
backend/services/cuttingSettingsService.js
Normal file
157
backend/services/cuttingSettingsService.js
Normal file
@@ -0,0 +1,157 @@
|
||||
const { pool } = require('../db');
|
||||
|
||||
const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM'];
|
||||
const FAMILY_KEY_SET = new Set(FAMILY_KEYS);
|
||||
const PRODUCT_TYPE_KEYS = [
|
||||
'finished_apparel',
|
||||
'finished_accessory',
|
||||
'raw_material',
|
||||
'packaging',
|
||||
'dtf_input',
|
||||
'dtf_service',
|
||||
'kit_bundle',
|
||||
'service',
|
||||
'machine_part',
|
||||
'equipment',
|
||||
'ignore_from_planning',
|
||||
'unknown'
|
||||
];
|
||||
const PRODUCT_TYPE_KEY_SET = new Set(PRODUCT_TYPE_KEYS);
|
||||
|
||||
const normalizeFamilyKey = (value) => {
|
||||
const familyKey = String(value || '').trim().toUpperCase();
|
||||
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
|
||||
};
|
||||
177
backend/services/databaseDiagnosticService.js
Normal file
177
backend/services/databaseDiagnosticService.js
Normal file
@@ -0,0 +1,177 @@
|
||||
const { pool } = require('../db');
|
||||
|
||||
const SAMPLE_LIMIT = 50;
|
||||
|
||||
const sampleSpecs = {
|
||||
catalog_categories: {
|
||||
columns: ['id', 'name', 'description', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
catalog_products: {
|
||||
columns: ['id', 'type', 'sku', 'name', 'category_id', 'composition', 'gramature', 'material_yield', 'width_cm', 'color', 'subcategory', 'sizes', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
consumption_references: {
|
||||
columns: ['id', 'product_id', 'material_product_id', 'color', 'general_yield', 'size_yields', 'size_areas', 'gramature', 'efficiency_percent', 'rib_g_per_piece', 'material_cost_per_kg', 'consumption_quantity', 'consumption_unit', 'source', 'last_production_order_id', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
cutting_family_rules: {
|
||||
columns: ['family_key', 'units_per_roll', 'updated_at'],
|
||||
orderBy: ['family_key']
|
||||
},
|
||||
cutting_product_overrides: {
|
||||
columns: ['product_id', 'family_key', 'color', 'size', 'product_type', 'planning_notes', 'updated_at'],
|
||||
orderBy: ['updated_at', 'product_id']
|
||||
},
|
||||
orders: {
|
||||
columns: ['id', 'pedido_id', 'data_pedido', 'data_pedido_date', 'valor_pedido', 'produto_id', 'produto_descricao', 'quantidade', 'valor_unitario', 'id_vendedor', 'nome_vendedor', 'marketplace', 'canal_venda', 'numero_ecommerce', 'created_at'],
|
||||
orderBy: ['data_pedido_date', 'created_at', 'id']
|
||||
},
|
||||
production_order_markers: {
|
||||
columns: ['id', 'production_order_id', 'label', 'color', 'created_at'],
|
||||
orderBy: ['created_at', 'id']
|
||||
},
|
||||
production_order_components: {
|
||||
columns: ['id', 'production_order_id', 'component_tiny_id', 'component_sku', 'component_name', 'quantity_per_unit', 'total_quantity', 'unit', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
production_order_steps: {
|
||||
columns: ['id', 'production_order_id', 'step_number', 'name', 'start_date', 'end_date', 'status', 'color', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
production_orders: {
|
||||
columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'notes', 'supplier', 'lot_code', 'roll_quantity', 'fabric_kg', 'rib_kg', 'yield_pieces_per_kg', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
stock: {
|
||||
columns: ['produto_id', 'nome', 'saldo', 'delta_estoque', 'updated_at'],
|
||||
orderBy: ['updated_at', 'produto_id']
|
||||
},
|
||||
stock_campaign_queue: {
|
||||
columns: ['id', 'base_product_name', 'produto_id', 'nome', 'saldo', 'delta_estoque', 'status', 'attempts', 'last_error', 'created_at', 'updated_at', 'sent_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
supply_fabric_plans: {
|
||||
columns: ['id', 'material', 'color', 'quantity_kg', 'supplier', 'priority', 'status', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
supply_movements: {
|
||||
columns: ['id', 'receipt_id', 'lot_id', 'type', 'category', 'product', 'quantity', 'unit', 'reason', 'created_at'],
|
||||
orderBy: ['created_at', 'id']
|
||||
},
|
||||
supply_receipts: {
|
||||
columns: ['id', 'category', 'product', 'quantity', 'unit', 'supplier', 'invoice', 'notes', 'status', 'created_at', 'updated_at', 'approved_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
supply_stock_lots: {
|
||||
columns: ['id', 'receipt_id', 'category', 'product', 'quantity', 'unit', 'supplier', 'invoice', 'status', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
}
|
||||
};
|
||||
|
||||
const quoteIdentifier = (identifier) => {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(identifier)) {
|
||||
throw new Error(`Unsafe database identifier: ${identifier}`);
|
||||
}
|
||||
|
||||
return `"${identifier}"`;
|
||||
};
|
||||
|
||||
const listPublicTables = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
return result.rows.map(row => row.table_name);
|
||||
};
|
||||
|
||||
const listPublicColumns = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name, ordinal_position
|
||||
`);
|
||||
|
||||
return result.rows.reduce((columnsByTable, row) => {
|
||||
if (!columnsByTable[row.table_name]) columnsByTable[row.table_name] = [];
|
||||
columnsByTable[row.table_name].push(row.column_name);
|
||||
return columnsByTable;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const countTableRows = async (tableName) => {
|
||||
const result = await pool.query(`SELECT COUNT(*)::int AS count FROM ${quoteIdentifier(tableName)}`);
|
||||
return result.rows[0]?.count || 0;
|
||||
};
|
||||
|
||||
const buildOrderClause = (spec, availableColumns) => {
|
||||
const orderColumns = spec.orderBy.filter(column => availableColumns.includes(column));
|
||||
if (!orderColumns.length) return '';
|
||||
|
||||
const clauses = orderColumns.map(column => {
|
||||
const direction = column === 'family_key' || column === 'product_id' || column === 'produto_id' ? 'ASC' : 'DESC';
|
||||
return `${quoteIdentifier(column)} ${direction}`;
|
||||
});
|
||||
|
||||
return ` ORDER BY ${clauses.join(', ')}`;
|
||||
};
|
||||
|
||||
const sampleTableRows = async (tableName, availableColumns) => {
|
||||
const spec = sampleSpecs[tableName];
|
||||
if (!spec) return null;
|
||||
|
||||
const selectedColumns = spec.columns.filter(column => availableColumns.includes(column));
|
||||
if (!selectedColumns.length) return null;
|
||||
|
||||
const selectClause = selectedColumns.map(quoteIdentifier).join(', ');
|
||||
const orderClause = buildOrderClause(spec, availableColumns);
|
||||
const result = await pool.query(
|
||||
`SELECT ${selectClause} FROM ${quoteIdentifier(tableName)}${orderClause} LIMIT $1`,
|
||||
[SAMPLE_LIMIT]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const buildDatabaseDiagnostic = async (user) => {
|
||||
const [tables, columnsByTable] = await Promise.all([
|
||||
listPublicTables(),
|
||||
listPublicColumns()
|
||||
]);
|
||||
|
||||
const counts = {};
|
||||
const samples = {};
|
||||
|
||||
for (const tableName of tables) {
|
||||
counts[tableName] = await countTableRows(tableName);
|
||||
|
||||
const sampleRows = await sampleTableRows(tableName, columnsByTable[tableName] || []);
|
||||
if (sampleRows) samples[tableName] = sampleRows;
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedBy: {
|
||||
role: user?.role || null,
|
||||
userId: user?.userId || null
|
||||
},
|
||||
sampleLimit: SAMPLE_LIMIT,
|
||||
privacy: {
|
||||
countsIncludeAllPublicTables: true,
|
||||
samplesExcludeTables: ['app_users', 'client_identity_tokens'],
|
||||
ordersSampleExcludesCustomerNameAndPhone: true,
|
||||
productionOrdersSampleExcludesTinyPayload: true
|
||||
},
|
||||
counts,
|
||||
samples
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
buildDatabaseDiagnostic
|
||||
};
|
||||
@@ -15,8 +15,9 @@ const upsertOrders = async (payload) => {
|
||||
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
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
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,
|
||||
@@ -25,7 +26,13 @@ const upsertOrders = async (payload) => {
|
||||
produto_descricao = EXCLUDED.produto_descricao,
|
||||
quantidade = EXCLUDED.quantidade,
|
||||
valor_unitario = EXCLUDED.valor_unitario,
|
||||
cliente_fone = EXCLUDED.cliente_fone,
|
||||
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
|
||||
`;
|
||||
|
||||
|
||||
1065
backend/services/productionOrderService.js
Normal file
1065
backend/services/productionOrderService.js
Normal file
File diff suppressed because it is too large
Load Diff
835
backend/services/supplyService.js
Normal file
835
backend/services/supplyService.js
Normal file
@@ -0,0 +1,835 @@
|
||||
const { pool } = require('../db');
|
||||
|
||||
const DEFAULT_SUPPLY_LOOKBACK_DAYS = 30;
|
||||
const DEFAULT_SUPPLY_COVERAGE_DAYS = 30;
|
||||
|
||||
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 normalizeNonNegativeNumber = (value) => {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const number = Number(String(value).replace(',', '.'));
|
||||
return Number.isFinite(number) && number >= 0 ? number : null;
|
||||
};
|
||||
|
||||
const normalizeKey = (value) => normalizeText(value)
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
|
||||
const normalizeSku = (value) => normalizeText(value).toUpperCase();
|
||||
|
||||
const normalizeUnit = (value) => {
|
||||
const unit = normalizeText(value).toLowerCase();
|
||||
if (['kg', 'quilo', 'quilos'].includes(unit)) return 'kg';
|
||||
if (['un', 'und', 'un.', 'unidade', 'unidades'].includes(unit)) return 'un.';
|
||||
return unit || 'un.';
|
||||
};
|
||||
|
||||
const mapReceipt = (row) => ({
|
||||
id: row.id,
|
||||
category: row.category,
|
||||
product: row.product,
|
||||
quantity: Number(row.quantity),
|
||||
unit: row.unit,
|
||||
supplier: row.supplier || '',
|
||||
invoice: row.invoice || '',
|
||||
notes: row.notes || '',
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
approvedAt: row.approved_at
|
||||
});
|
||||
|
||||
const mapLot = (row) => ({
|
||||
id: row.id,
|
||||
receiptId: row.receipt_id,
|
||||
category: row.category,
|
||||
product: row.product,
|
||||
quantity: Number(row.quantity),
|
||||
unit: row.unit,
|
||||
supplier: row.supplier || '',
|
||||
invoice: row.invoice || '',
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
const mapMovement = (row) => ({
|
||||
id: row.id,
|
||||
receiptId: row.receipt_id,
|
||||
lotId: row.lot_id,
|
||||
type: row.type,
|
||||
category: row.category,
|
||||
product: row.product,
|
||||
quantity: Number(row.quantity),
|
||||
unit: row.unit,
|
||||
reason: row.reason || '',
|
||||
createdAt: row.created_at
|
||||
});
|
||||
|
||||
const mapFabricPlan = (row) => ({
|
||||
id: row.id,
|
||||
material: row.material,
|
||||
color: row.color || '',
|
||||
quantityKg: Number(row.quantity_kg),
|
||||
supplier: row.supplier || '',
|
||||
priority: row.priority,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
const createValidationError = (message) => {
|
||||
const error = new Error(message);
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
};
|
||||
|
||||
const listReceipts = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at
|
||||
FROM supply_receipts
|
||||
ORDER BY created_at DESC, id DESC
|
||||
`);
|
||||
return result.rows.map(mapReceipt);
|
||||
};
|
||||
|
||||
const listLots = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
|
||||
FROM supply_stock_lots
|
||||
WHERE status = 'active'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
`);
|
||||
return result.rows.map(mapLot);
|
||||
};
|
||||
|
||||
const listMovements = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT id, receipt_id, lot_id, type, category, product, quantity, unit, reason, created_at
|
||||
FROM supply_movements
|
||||
ORDER BY created_at DESC, id DESC
|
||||
`);
|
||||
return result.rows.map(mapMovement);
|
||||
};
|
||||
|
||||
const listFabricPlans = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT id, material, color, quantity_kg, supplier, priority, status, created_at, updated_at
|
||||
FROM supply_fabric_plans
|
||||
WHERE status = 'active'
|
||||
ORDER BY
|
||||
CASE priority
|
||||
WHEN 'Crítico' THEN 1
|
||||
WHEN 'Atenção' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
created_at DESC,
|
||||
id DESC
|
||||
`);
|
||||
return result.rows.map(mapFabricPlan);
|
||||
};
|
||||
|
||||
const buildPurchaseNeeds = (plans, lots, receipts) => {
|
||||
const needsByMaterial = new Map();
|
||||
|
||||
plans.forEach(plan => {
|
||||
const key = normalizeKey(plan.material);
|
||||
if (!key) return;
|
||||
|
||||
const current = needsByMaterial.get(key) || {
|
||||
material: plan.material,
|
||||
plannedKg: 0,
|
||||
stockKg: 0,
|
||||
pendingKg: 0,
|
||||
purchaseKg: 0,
|
||||
priority: 'Normal',
|
||||
suppliers: new Set(),
|
||||
colors: new Set()
|
||||
};
|
||||
|
||||
current.plannedKg += plan.quantityKg;
|
||||
if (plan.supplier) current.suppliers.add(plan.supplier);
|
||||
if (plan.color) current.colors.add(plan.color);
|
||||
if (plan.priority === 'Crítico') current.priority = 'Crítico';
|
||||
if (plan.priority === 'Atenção' && current.priority !== 'Crítico') current.priority = 'Atenção';
|
||||
needsByMaterial.set(key, current);
|
||||
});
|
||||
|
||||
lots.forEach(lot => {
|
||||
if (lot.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(lot.product));
|
||||
if (need) need.stockKg += lot.quantity;
|
||||
});
|
||||
|
||||
receipts.forEach(receipt => {
|
||||
if (receipt.status !== 'pending' || receipt.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(receipt.product));
|
||||
if (need) need.pendingKg += receipt.quantity;
|
||||
});
|
||||
|
||||
return Array.from(needsByMaterial.values())
|
||||
.map(need => {
|
||||
const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0);
|
||||
let status = 'ok';
|
||||
if (purchaseKg > 0 && (need.priority === 'Crítico' || need.stockKg === 0)) status = 'critical';
|
||||
else if (purchaseKg > 0) status = 'attention';
|
||||
|
||||
return {
|
||||
material: need.material,
|
||||
plannedKg: need.plannedKg,
|
||||
stockKg: need.stockKg,
|
||||
pendingKg: need.pendingKg,
|
||||
purchaseKg,
|
||||
priority: need.priority,
|
||||
status,
|
||||
suppliers: Array.from(need.suppliers),
|
||||
colors: Array.from(need.colors)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const statusOrder = { critical: 1, attention: 2, ok: 3 };
|
||||
return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material);
|
||||
});
|
||||
};
|
||||
|
||||
const getReferenceYield = (reference) => {
|
||||
if (reference.general_yield !== null && Number(reference.general_yield) > 0) {
|
||||
return Number(reference.general_yield);
|
||||
}
|
||||
|
||||
const sizeYields = reference.size_yields && typeof reference.size_yields === 'object'
|
||||
? Object.values(reference.size_yields).map(Number).filter(value => Number.isFinite(value) && value > 0)
|
||||
: [];
|
||||
|
||||
if (!sizeYields.length) return null;
|
||||
return sizeYields.reduce((total, value) => total + value, 0) / sizeYields.length;
|
||||
};
|
||||
|
||||
const listProjectDemandRows = async () => {
|
||||
const result = await pool.query(`
|
||||
WITH bounds AS (
|
||||
SELECT MAX(data_pedido_date) AS end_date
|
||||
FROM orders
|
||||
WHERE data_pedido_date IS NOT NULL
|
||||
),
|
||||
period_orders AS (
|
||||
SELECT
|
||||
produto_id,
|
||||
MAX(produto_descricao) AS product_name,
|
||||
SUM(quantidade)::numeric AS quantity_sold
|
||||
FROM orders, bounds
|
||||
WHERE data_pedido_date IS NOT NULL
|
||||
AND bounds.end_date IS NOT NULL
|
||||
AND data_pedido_date >= (bounds.end_date - ($1::int - 1) * INTERVAL '1 day')::date
|
||||
AND data_pedido_date <= bounds.end_date
|
||||
GROUP BY produto_id
|
||||
)
|
||||
SELECT
|
||||
COALESCE(period_orders.produto_id, stock.produto_id) AS product_id,
|
||||
COALESCE(NULLIF(period_orders.product_name, ''), NULLIF(stock.nome, ''), 'Produto sem nome') AS product_name,
|
||||
COALESCE(period_orders.quantity_sold, 0)::numeric AS quantity_sold,
|
||||
COALESCE(stock.saldo, 0)::numeric AS stock_quantity
|
||||
FROM period_orders
|
||||
FULL OUTER JOIN stock ON stock.produto_id = period_orders.produto_id
|
||||
WHERE COALESCE(period_orders.produto_id, stock.produto_id, '') <> ''
|
||||
ORDER BY quantity_sold DESC, product_name;
|
||||
`, [DEFAULT_SUPPLY_LOOKBACK_DAYS]);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const listConsumptionReferenceRows = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
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.consumption_quantity,
|
||||
r.consumption_unit,
|
||||
r.source
|
||||
FROM consumption_references r
|
||||
JOIN catalog_products p ON p.id = r.product_id
|
||||
LEFT JOIN catalog_products m ON m.id = r.material_product_id
|
||||
ORDER BY p.sku, r.color NULLS FIRST;
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const mergeNeedLine = (needsByMaterial, key, patch) => {
|
||||
const current = needsByMaterial.get(key) || {
|
||||
material: patch.material,
|
||||
plannedKg: 0,
|
||||
stockKg: 0,
|
||||
pendingKg: 0,
|
||||
purchaseKg: 0,
|
||||
priority: patch.priority || 'Normal',
|
||||
suppliers: new Set(),
|
||||
colors: new Set(),
|
||||
unit: patch.unit || 'kg',
|
||||
source: patch.source || 'manual_plan',
|
||||
missingReference: Boolean(patch.missingReference),
|
||||
products: []
|
||||
};
|
||||
|
||||
current.plannedKg += patch.plannedKg || 0;
|
||||
current.priority = patch.priority === 'Crítico' || current.priority === 'Crítico'
|
||||
? 'Crítico'
|
||||
: patch.priority === 'Atenção' || current.priority === 'Atenção'
|
||||
? 'Atenção'
|
||||
: current.priority;
|
||||
current.missingReference = current.missingReference || Boolean(patch.missingReference);
|
||||
current.source = current.source === patch.source ? current.source : 'mixed';
|
||||
if (patch.supplier) current.suppliers.add(patch.supplier);
|
||||
if (patch.color) current.colors.add(patch.color);
|
||||
if (patch.product) current.products.push(patch.product);
|
||||
|
||||
needsByMaterial.set(key, current);
|
||||
return current;
|
||||
};
|
||||
|
||||
const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
const [demandRows, referenceRows] = await Promise.all([
|
||||
listProjectDemandRows(),
|
||||
listConsumptionReferenceRows()
|
||||
]);
|
||||
const referencesBySku = referenceRows.reduce((references, reference) => {
|
||||
const sku = normalizeSku(reference.product_sku);
|
||||
if (!sku) return references;
|
||||
references.set(sku, [...(references.get(sku) || []), reference]);
|
||||
return references;
|
||||
}, new Map());
|
||||
const needsByMaterial = new Map();
|
||||
|
||||
demandRows.forEach(row => {
|
||||
const productId = normalizeSku(row.product_id);
|
||||
if (!productId) return;
|
||||
|
||||
const quantitySold = Number(row.quantity_sold || 0);
|
||||
const stockQuantity = Number(row.stock_quantity || 0);
|
||||
const projectedDemand = quantitySold * (DEFAULT_SUPPLY_COVERAGE_DAYS / DEFAULT_SUPPLY_LOOKBACK_DAYS);
|
||||
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
|
||||
if (suggestedQuantity <= 0) return;
|
||||
|
||||
const references = referencesBySku.get(productId) || [];
|
||||
if (!references.length) {
|
||||
mergeNeedLine(needsByMaterial, `missing:${productId}`, {
|
||||
material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`,
|
||||
plannedKg: suggestedQuantity,
|
||||
priority: 'Crítico',
|
||||
unit: 'un.',
|
||||
source: 'project_demand',
|
||||
missingReference: true,
|
||||
product: {
|
||||
productId,
|
||||
name: normalizeText(row.product_name),
|
||||
suggestedQuantity,
|
||||
quantitySold,
|
||||
stockQuantity
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
references.forEach(reference => {
|
||||
const consumptionQuantity = Number(reference.consumption_quantity || 0);
|
||||
const consumptionUnit = normalizeUnit(reference.consumption_unit);
|
||||
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
|
||||
|
||||
if (consumptionQuantity > 0) {
|
||||
mergeNeedLine(needsByMaterial, `${consumptionUnit}:${normalizeKey(materialName)}`, {
|
||||
material: materialName,
|
||||
plannedKg: suggestedQuantity * consumptionQuantity,
|
||||
priority: 'Atenção',
|
||||
unit: consumptionUnit,
|
||||
source: reference.source || 'project_demand',
|
||||
color: reference.color,
|
||||
product: {
|
||||
productId,
|
||||
name: normalizeText(row.product_name),
|
||||
suggestedQuantity,
|
||||
quantitySold,
|
||||
stockQuantity,
|
||||
consumptionQuantity,
|
||||
consumptionUnit
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const yieldPerKg = getReferenceYield(reference);
|
||||
if (!yieldPerKg) {
|
||||
mergeNeedLine(needsByMaterial, `missing-yield:${productId}:${reference.material_product_id || 'material'}`, {
|
||||
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
|
||||
plannedKg: suggestedQuantity,
|
||||
priority: 'Crítico',
|
||||
unit: 'un.',
|
||||
source: 'project_demand',
|
||||
missingReference: true,
|
||||
color: reference.color,
|
||||
product: {
|
||||
productId,
|
||||
name: normalizeText(row.product_name),
|
||||
suggestedQuantity,
|
||||
quantitySold,
|
||||
stockQuantity
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
mergeNeedLine(needsByMaterial, `kg:${normalizeKey(materialName)}`, {
|
||||
material: materialName,
|
||||
plannedKg: suggestedQuantity / yieldPerKg,
|
||||
priority: 'Atenção',
|
||||
unit: 'kg',
|
||||
source: reference.source || 'project_demand',
|
||||
color: reference.color,
|
||||
product: {
|
||||
productId,
|
||||
name: normalizeText(row.product_name),
|
||||
suggestedQuantity,
|
||||
quantitySold,
|
||||
stockQuantity,
|
||||
yieldPerKg
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
lots.forEach(lot => {
|
||||
const need = needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
|
||||
if (need) need.stockKg += lot.quantity;
|
||||
});
|
||||
|
||||
receipts.forEach(receipt => {
|
||||
if (receipt.status !== 'pending') return;
|
||||
const need = needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
|
||||
if (need) need.pendingKg += receipt.quantity;
|
||||
});
|
||||
|
||||
return Array.from(needsByMaterial.values()).map(need => {
|
||||
const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0);
|
||||
let status = 'ok';
|
||||
if (purchaseKg > 0 && (need.priority === 'Crítico' || need.stockKg === 0)) status = 'critical';
|
||||
else if (purchaseKg > 0) status = 'attention';
|
||||
|
||||
return {
|
||||
material: need.material,
|
||||
plannedKg: need.plannedKg,
|
||||
stockKg: need.stockKg,
|
||||
pendingKg: need.pendingKg,
|
||||
purchaseKg,
|
||||
priority: need.priority,
|
||||
status,
|
||||
suppliers: Array.from(need.suppliers),
|
||||
colors: Array.from(need.colors),
|
||||
unit: need.unit,
|
||||
source: need.source,
|
||||
missingReference: need.missingReference,
|
||||
products: need.products
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
|
||||
const mergedByKey = new Map();
|
||||
|
||||
[...manualNeeds, ...projectNeeds].forEach(need => {
|
||||
const key = `${need.unit || 'kg'}:${normalizeKey(need.material)}:${need.missingReference ? 'missing' : 'mapped'}`;
|
||||
const current = mergedByKey.get(key);
|
||||
if (!current) {
|
||||
mergedByKey.set(key, {
|
||||
...need,
|
||||
suppliers: new Set(need.suppliers || []),
|
||||
colors: new Set(need.colors || []),
|
||||
products: [...(need.products || [])]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
current.plannedKg += need.plannedKg;
|
||||
current.stockKg += need.stockKg;
|
||||
current.pendingKg += need.pendingKg;
|
||||
current.purchaseKg += need.purchaseKg;
|
||||
current.priority = need.priority === 'Crítico' || current.priority === 'Crítico'
|
||||
? 'Crítico'
|
||||
: need.priority === 'Atenção' || current.priority === 'Atenção'
|
||||
? 'Atenção'
|
||||
: current.priority;
|
||||
current.status = current.status === 'critical' || need.status === 'critical'
|
||||
? 'critical'
|
||||
: current.status === 'attention' || need.status === 'attention'
|
||||
? 'attention'
|
||||
: 'ok';
|
||||
current.source = current.source === need.source ? current.source : 'mixed';
|
||||
current.missingReference = current.missingReference || Boolean(need.missingReference);
|
||||
(need.suppliers || []).forEach(supplier => current.suppliers.add(supplier));
|
||||
(need.colors || []).forEach(color => current.colors.add(color));
|
||||
current.products.push(...(need.products || []));
|
||||
});
|
||||
|
||||
return Array.from(mergedByKey.values())
|
||||
.map(need => ({
|
||||
...need,
|
||||
suppliers: Array.from(need.suppliers),
|
||||
colors: Array.from(need.colors)
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const statusOrder = { critical: 1, attention: 2, ok: 3 };
|
||||
return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material);
|
||||
});
|
||||
};
|
||||
|
||||
const buildStats = (receipts, lots, purchaseNeeds) => {
|
||||
const totalQuantityKg = lots.reduce((total, lot) => (
|
||||
lot.unit === 'kg' ? total + lot.quantity : total
|
||||
), 0);
|
||||
|
||||
return {
|
||||
totalQuantityKg,
|
||||
activeLots: lots.length,
|
||||
rolls: lots.filter(lot => lot.unit === 'rolos').reduce((total, lot) => total + lot.quantity, 0),
|
||||
alerts: purchaseNeeds.filter(need => need.status !== 'ok').length,
|
||||
pendingReceipts: receipts.filter(receipt => receipt.status === 'pending').length,
|
||||
approvedReceipts: receipts.filter(receipt => receipt.status === 'approved').length
|
||||
};
|
||||
};
|
||||
|
||||
const getSupplySummary = async () => {
|
||||
const [receipts, lots, movements, fabricPlans] = await Promise.all([
|
||||
listReceipts(),
|
||||
listLots(),
|
||||
listMovements(),
|
||||
listFabricPlans()
|
||||
]);
|
||||
const manualPurchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts);
|
||||
const projectPurchaseNeeds = await buildProjectPurchaseNeeds(lots, receipts);
|
||||
const purchaseNeeds = mergePurchaseNeeds(manualPurchaseNeeds, projectPurchaseNeeds);
|
||||
|
||||
return {
|
||||
receipts,
|
||||
lots,
|
||||
movements,
|
||||
fabricPlans,
|
||||
purchaseNeeds,
|
||||
stats: buildStats(receipts, lots, purchaseNeeds)
|
||||
};
|
||||
};
|
||||
|
||||
const listPurchaseNeeds = async () => {
|
||||
const [plans, lots, receipts] = await Promise.all([
|
||||
listFabricPlans(),
|
||||
listLots(),
|
||||
listReceipts()
|
||||
]);
|
||||
|
||||
return mergePurchaseNeeds(
|
||||
buildPurchaseNeeds(plans, lots, receipts),
|
||||
await buildProjectPurchaseNeeds(lots, receipts)
|
||||
);
|
||||
};
|
||||
|
||||
const createReceipt = async (payload) => {
|
||||
const category = normalizeText(payload.category);
|
||||
const product = normalizeText(payload.product);
|
||||
const quantity = normalizeNumber(payload.quantity);
|
||||
const unit = normalizeText(payload.unit) || 'kg';
|
||||
|
||||
if (!category) throw createValidationError('Categoria é obrigatória.');
|
||||
if (!product) throw createValidationError('Produto ou material é obrigatório.');
|
||||
if (!quantity) throw createValidationError('Quantidade deve ser maior que zero.');
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO supply_receipts (
|
||||
category, product, quantity, unit, supplier, invoice, notes, status, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', CURRENT_TIMESTAMP)
|
||||
RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at
|
||||
`, [
|
||||
category,
|
||||
product,
|
||||
quantity,
|
||||
unit,
|
||||
normalizeText(payload.supplier) || null,
|
||||
normalizeText(payload.invoice) || null,
|
||||
normalizeText(payload.notes) || null
|
||||
]);
|
||||
|
||||
return mapReceipt(result.rows[0]);
|
||||
};
|
||||
|
||||
const approveReceipt = async (id) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const receiptResult = await client.query(`
|
||||
SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at
|
||||
FROM supply_receipts
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [id]);
|
||||
|
||||
if (!receiptResult.rowCount) {
|
||||
throw createValidationError('Recebimento não encontrado.');
|
||||
}
|
||||
|
||||
const receipt = receiptResult.rows[0];
|
||||
if (receipt.status === 'approved') {
|
||||
await client.query('COMMIT');
|
||||
return mapReceipt(receipt);
|
||||
}
|
||||
|
||||
const updatedReceiptResult = await client.query(`
|
||||
UPDATE supply_receipts
|
||||
SET status = 'approved',
|
||||
approved_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at
|
||||
`, [id]);
|
||||
|
||||
const lotResult = await client.query(`
|
||||
INSERT INTO supply_stock_lots (
|
||||
receipt_id, category, product, quantity, unit, supplier, invoice, status, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
`, [
|
||||
receipt.id,
|
||||
receipt.category,
|
||||
receipt.product,
|
||||
receipt.quantity,
|
||||
receipt.unit,
|
||||
receipt.supplier,
|
||||
receipt.invoice
|
||||
]);
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO supply_movements (
|
||||
receipt_id, lot_id, type, category, product, quantity, unit, reason
|
||||
)
|
||||
VALUES ($1, $2, 'receipt', $3, $4, $5, $6, $7)
|
||||
`, [
|
||||
receipt.id,
|
||||
lotResult.rows[0].id,
|
||||
receipt.category,
|
||||
receipt.product,
|
||||
receipt.quantity,
|
||||
receipt.unit,
|
||||
`Recebimento aprovado${receipt.invoice ? ` · NF ${receipt.invoice}` : ''}`
|
||||
]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return mapReceipt(updatedReceiptResult.rows[0]);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteReceipt = async (id) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM supply_movements WHERE receipt_id = $1', [id]);
|
||||
await client.query('DELETE FROM supply_stock_lots WHERE receipt_id = $1', [id]);
|
||||
await client.query('DELETE FROM supply_receipts WHERE id = $1', [id]);
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
const createFabricPlan = async (payload) => {
|
||||
const material = normalizeText(payload.material);
|
||||
const quantityKg = normalizeNumber(payload.quantityKg);
|
||||
|
||||
if (!material) throw createValidationError('Malha ou tecido é obrigatório.');
|
||||
if (!quantityKg) throw createValidationError('Quantidade deve ser maior que zero.');
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO supply_fabric_plans (
|
||||
material, color, quantity_kg, supplier, priority, status, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 'active', CURRENT_TIMESTAMP)
|
||||
RETURNING id, material, color, quantity_kg, supplier, priority, status, created_at, updated_at
|
||||
`, [
|
||||
material,
|
||||
normalizeText(payload.color) || 'Todas as cores',
|
||||
quantityKg,
|
||||
normalizeText(payload.supplier) || null,
|
||||
normalizeText(payload.priority) || 'Normal'
|
||||
]);
|
||||
|
||||
return mapFabricPlan(result.rows[0]);
|
||||
};
|
||||
|
||||
const deleteFabricPlan = async (id) => {
|
||||
await pool.query(`
|
||||
UPDATE supply_fabric_plans
|
||||
SET status = 'removed',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [id]);
|
||||
};
|
||||
|
||||
const updateLotQuantity = async (client, lotId, quantity) => {
|
||||
const status = quantity > 0 ? 'active' : 'depleted';
|
||||
const result = await client.query(`
|
||||
UPDATE supply_stock_lots
|
||||
SET quantity = $2,
|
||||
status = $3,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
RETURNING id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
|
||||
`, [lotId, quantity, status]);
|
||||
|
||||
return mapLot(result.rows[0]);
|
||||
};
|
||||
|
||||
const adjustInventoryLot = async (id, payload) => {
|
||||
const countedQuantity = normalizeNonNegativeNumber(payload.countedQuantity);
|
||||
const reason = normalizeText(payload.reason);
|
||||
|
||||
if (countedQuantity === null) throw createValidationError('Quantidade contada deve ser zero ou maior.');
|
||||
if (!reason) throw createValidationError('Justificativa do ajuste é obrigatória.');
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const lotResult = await client.query(`
|
||||
SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
|
||||
FROM supply_stock_lots
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [id]);
|
||||
|
||||
if (!lotResult.rowCount) throw createValidationError('Lote não encontrado.');
|
||||
|
||||
const lot = lotResult.rows[0];
|
||||
const currentQuantity = Number(lot.quantity);
|
||||
const difference = countedQuantity - currentQuantity;
|
||||
const updatedLot = await updateLotQuantity(client, lot.id, countedQuantity);
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO supply_movements (
|
||||
lot_id, type, category, product, quantity, unit, reason
|
||||
)
|
||||
VALUES ($1, 'inventory_adjustment', $2, $3, $4, $5, $6)
|
||||
`, [
|
||||
lot.id,
|
||||
lot.category,
|
||||
lot.product,
|
||||
difference,
|
||||
lot.unit,
|
||||
`${reason} · sistema ${currentQuantity} ${lot.unit} · contado ${countedQuantity} ${lot.unit}`
|
||||
]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return updatedLot;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
const consumeLotForProduction = async (id, payload) => {
|
||||
const quantity = normalizeNumber(payload.quantity);
|
||||
const reason = normalizeText(payload.reason);
|
||||
const productionOrderNumber = normalizeText(payload.productionOrderNumber);
|
||||
|
||||
if (!quantity) throw createValidationError('Quantidade de saída deve ser maior que zero.');
|
||||
if (!productionOrderNumber && !reason) throw createValidationError('Informe a OP ou uma justificativa para a saída.');
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const lotResult = await client.query(`
|
||||
SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
|
||||
FROM supply_stock_lots
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [id]);
|
||||
|
||||
if (!lotResult.rowCount) throw createValidationError('Lote não encontrado.');
|
||||
|
||||
const lot = lotResult.rows[0];
|
||||
const currentQuantity = Number(lot.quantity);
|
||||
if (lot.status !== 'active' || currentQuantity <= 0) throw createValidationError('Lote sem saldo disponível.');
|
||||
if (quantity > currentQuantity) throw createValidationError('Quantidade de saída maior que o saldo do lote.');
|
||||
|
||||
const updatedLot = await updateLotQuantity(client, lot.id, currentQuantity - quantity);
|
||||
const movementReason = [
|
||||
productionOrderNumber ? `OP ${productionOrderNumber}` : '',
|
||||
reason
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO supply_movements (
|
||||
lot_id, type, category, product, quantity, unit, reason
|
||||
)
|
||||
VALUES ($1, 'production_exit', $2, $3, $4, $5, $6)
|
||||
`, [
|
||||
lot.id,
|
||||
lot.category,
|
||||
lot.product,
|
||||
-quantity,
|
||||
lot.unit,
|
||||
movementReason || 'Saída para produção'
|
||||
]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return updatedLot;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
adjustInventoryLot,
|
||||
approveReceipt,
|
||||
consumeLotForProduction,
|
||||
createFabricPlan,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
getSupplySummary,
|
||||
buildProjectPurchaseNeeds,
|
||||
buildPurchaseNeeds,
|
||||
listFabricPlans,
|
||||
listLots,
|
||||
listMovements,
|
||||
listPurchaseNeeds,
|
||||
listReceipts
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ const {
|
||||
formatProductNameForDisplay,
|
||||
groupCampaignRows,
|
||||
groupCampaignRowsByBaseProduct,
|
||||
isCampaignEligibleProductName,
|
||||
mapCampaignProducts
|
||||
} = require('../services/campaignFormatter');
|
||||
|
||||
@@ -58,6 +59,39 @@ test('formatProductNameForDisplay applies customer-facing campaign aliases', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('isCampaignEligibleProductName allows only customer-facing apparel campaigns', () => {
|
||||
[
|
||||
'Camiseta Premium Cor Bordo',
|
||||
'Camiseta Premium Cor Preto',
|
||||
'Moletom Canguru Premium Cor Preto',
|
||||
'IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)',
|
||||
'BASE LISA CAMISETA COR PRETO TAMANHO - G',
|
||||
'BASE LISA MOLETOM CANGURU COR PRETO'
|
||||
].forEach(name => {
|
||||
assert.equal(isCampaignEligibleProductName(name), true, name);
|
||||
});
|
||||
|
||||
[
|
||||
'Ilhos Com Arruela',
|
||||
'Atacador 001 Chato Preto 1,20 Mt',
|
||||
'2099 Ribana 2x1 Cor Bordo',
|
||||
'2001.09 Ribana 2x1 Cor Cinza',
|
||||
'2001.09 Malha Camiseta 30oe Cor Cinza',
|
||||
'201 Malha Camiseta 30oe Cor Preto',
|
||||
'4006 Malha Camiseta 30oe Cor Marinho',
|
||||
'TINTA DTF 1 LITRO - BRANCO',
|
||||
'FILME DTF ROLO 60CM',
|
||||
'POLIAMIDA EM PÓ PARA DTF - 1 KG',
|
||||
'SALDO ESTOQUE CAMISETA'
|
||||
].forEach(name => {
|
||||
assert.equal(isCampaignEligibleProductName(name), false, name);
|
||||
});
|
||||
});
|
||||
|
||||
test('isCampaignEligibleProductName keeps accessories out of WhatsApp apparel campaigns', () => {
|
||||
assert.equal(isCampaignEligibleProductName('BONÉ - PRETO'), false);
|
||||
});
|
||||
|
||||
test('mapCampaignProducts accumulates split deltas by base product', () => {
|
||||
const groups = groupCampaignRowsByBaseProduct([
|
||||
row({ id: 1, delta_estoque: 10, produto_id: 'SKU-P', nome: 'Produto Split TAMANHO - P' }),
|
||||
|
||||
111
backend/test/campaignService.test.js
Normal file
111
backend/test/campaignService.test.js
Normal file
@@ -0,0 +1,111 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const withCampaignService = async (queryHandler, callback) => {
|
||||
const dbPath = require.resolve('../db');
|
||||
const servicePath = require.resolve('../services/campaignService');
|
||||
const originalDbCache = require.cache[dbPath];
|
||||
const originalServiceCache = require.cache[servicePath];
|
||||
const queries = [];
|
||||
|
||||
delete require.cache[servicePath];
|
||||
require.cache[dbPath] = {
|
||||
id: dbPath,
|
||||
filename: dbPath,
|
||||
loaded: true,
|
||||
exports: {
|
||||
pool: {
|
||||
query: async (sql, params = []) => {
|
||||
queries.push({ sql, params });
|
||||
return queryHandler(sql, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const service = require('../services/campaignService');
|
||||
return await callback(service, queries);
|
||||
} finally {
|
||||
delete require.cache[servicePath];
|
||||
if (originalServiceCache) {
|
||||
require.cache[servicePath] = originalServiceCache;
|
||||
}
|
||||
if (originalDbCache) {
|
||||
require.cache[dbPath] = originalDbCache;
|
||||
} else {
|
||||
delete require.cache[dbPath];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test('getTopClientsForCampaign returns top clients for an explicit date range', async () => {
|
||||
await withCampaignService(async () => ({
|
||||
rows: [
|
||||
{
|
||||
nome: 'Cliente A',
|
||||
fone: '5516999999901',
|
||||
total_gasto: '1234.50',
|
||||
total_comprado: '18',
|
||||
total_pedidos: 4,
|
||||
ultima_compra: '2026-07-27',
|
||||
telefones: ['5516999999901', '5516999999902']
|
||||
}
|
||||
]
|
||||
}), async ({ getTopClientsForCampaign }, queries) => {
|
||||
const result = await getTopClientsForCampaign({
|
||||
start: '2026-06-28',
|
||||
end: '2026-07-27',
|
||||
limit: '1000'
|
||||
});
|
||||
|
||||
assert.equal(result.start, '2026-06-28');
|
||||
assert.equal(result.end, '2026-07-27');
|
||||
assert.equal(result.limit, 1000);
|
||||
assert.equal(result.count, 1);
|
||||
assert.deepEqual(result.customers[0], {
|
||||
nome: 'Cliente A',
|
||||
fone: '5516999999901',
|
||||
total_gasto: 1234.5,
|
||||
total_comprado: 18,
|
||||
total_pedidos: 4,
|
||||
ultima_compra: '2026-07-27',
|
||||
telefones: ['5516999999901', '5516999999902']
|
||||
});
|
||||
|
||||
assert.equal(queries.length, 1);
|
||||
assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 1000]);
|
||||
assert.match(queries[0].sql, /GROUP BY whatsapp_phone/);
|
||||
assert.doesNotMatch(queries[0].sql, /GROUP BY COALESCE\(canonical_customer_name, whatsapp_phone\)/);
|
||||
assert.match(queries[0].sql, /ARRAY_AGG\(DISTINCT whatsapp_phone\)/);
|
||||
assert.match(queries[0].sql, /ORDER BY total_gasto DESC/);
|
||||
});
|
||||
});
|
||||
|
||||
test('getTopClientsForCampaign derives an inclusive 30 day range from the end date', async () => {
|
||||
await withCampaignService(async () => ({ rows: [] }), async ({ getTopClientsForCampaign }, queries) => {
|
||||
const result = await getTopClientsForCampaign({
|
||||
days: '30',
|
||||
end: '2026-07-27'
|
||||
});
|
||||
|
||||
assert.equal(result.start, '2026-06-28');
|
||||
assert.equal(result.end, '2026-07-27');
|
||||
assert.equal(result.limit, 1000);
|
||||
assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 1000]);
|
||||
});
|
||||
});
|
||||
|
||||
test('getTopClientsForCampaign normalizes phones and groups one row per WhatsApp number', async () => {
|
||||
await withCampaignService(async () => ({ rows: [] }), async ({ getTopClientsForCampaign }, queries) => {
|
||||
await getTopClientsForCampaign({
|
||||
days: '30',
|
||||
end: '2026-07-27'
|
||||
});
|
||||
|
||||
assert.match(queries[0].sql, /regexp_replace\(COALESCE\(cliente_fone, ''\), '\\D', '', 'g'\)/);
|
||||
assert.match(queries[0].sql, /WHEN length\(/);
|
||||
assert.match(queries[0].sql, /'55' \|\|/);
|
||||
assert.match(queries[0].sql, /GROUP BY whatsapp_phone/);
|
||||
});
|
||||
});
|
||||
50
backend/test/cuttingSettingsService.test.js
Normal file
50
backend/test/cuttingSettingsService.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
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('OUTROS'), '');
|
||||
assert.equal(normalizeFamilyKey('unknown'), '');
|
||||
});
|
||||
|
||||
test('normalizeFamilyYields keeps positive numeric yield rules', () => {
|
||||
assert.deepEqual(normalizeFamilyYields({
|
||||
BLCS: '50',
|
||||
BLOS: 0,
|
||||
BLMC: -1,
|
||||
BLPM: '12.5',
|
||||
OUTROS: 99
|
||||
}), {
|
||||
BLCS: 50,
|
||||
BLPM: 12.5
|
||||
});
|
||||
});
|
||||
|
||||
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: '' }
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { normalizeOrderDate, normalizeOrderPayload } = require('../mappers/orderMapper');
|
||||
const { formatOrderRow, normalizeOrderDate, normalizeOrderPayload } = require('../mappers/orderMapper');
|
||||
|
||||
test('normalizeOrderDate accepts Brazilian display dates', () => {
|
||||
assert.equal(normalizeOrderDate('28/05/2026'), '2026-05-28');
|
||||
@@ -35,3 +35,83 @@ test('normalizeOrderPayload includes normalized date without changing display da
|
||||
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');
|
||||
});
|
||||
|
||||
140
backend/test/ordersService.test.js
Normal file
140
backend/test/ordersService.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
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');
|
||||
});
|
||||
275
backend/test/productCompositionService.test.js
Normal file
275
backend/test/productCompositionService.test.js
Normal file
@@ -0,0 +1,275 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { pool } = require('../db');
|
||||
const productionOrderRouter = require('../routes/productionOrderRoutes');
|
||||
const analyticsRouter = require('../routes/analyticsRoutes');
|
||||
|
||||
const getRouteHandler = (router, method, path) => {
|
||||
const route = router.stack.find(layer => layer.route?.path === path && layer.route.methods[method]);
|
||||
if (!route) throw new Error(`Route not found: ${method.toUpperCase()} ${path}`);
|
||||
return route.route.stack.at(-1).handle;
|
||||
};
|
||||
|
||||
const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync');
|
||||
const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition');
|
||||
const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions');
|
||||
|
||||
const invokeHandler = async (handler, req) => {
|
||||
let statusCode = 200;
|
||||
let body;
|
||||
const res = {
|
||||
status(code) {
|
||||
statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload) {
|
||||
body = payload;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
await handler(req, res);
|
||||
return { statusCode, body };
|
||||
};
|
||||
|
||||
const compositionPayload = (components = [
|
||||
{
|
||||
componentTinyId: '919498232',
|
||||
componentName: 'ETIQUETA DE TAMANHO GG',
|
||||
componentSku: 'ETIQ.T.GG',
|
||||
quantityPerUnit: '1',
|
||||
totalQuantity: '1',
|
||||
unit: 'UN'
|
||||
},
|
||||
{
|
||||
componentTinyId: '976059144',
|
||||
componentName: '6118 MALHA CAMISETA 30OE COR CAFE',
|
||||
componentSku: 'MC.30.CAF',
|
||||
quantityPerUnit: '0.1851',
|
||||
totalQuantity: '0.1851',
|
||||
unit: 'KG'
|
||||
}
|
||||
]) => ({
|
||||
order: {
|
||||
tinyId: 'STRUCTURE-V3-976058813',
|
||||
number: 'STRUCTURE-V3-BLCS.CAF.GG',
|
||||
status: 'completed',
|
||||
productSku: 'BLCS.CAF.GG',
|
||||
productDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG',
|
||||
quantity: '1',
|
||||
unit: 'UN',
|
||||
notes: 'Composição sincronizada da API pública Olist V3.'
|
||||
},
|
||||
components,
|
||||
steps: []
|
||||
});
|
||||
|
||||
const withFakeDatabase = async (run) => {
|
||||
const originalConnect = pool.connect;
|
||||
const originalQuery = pool.query;
|
||||
const state = {
|
||||
nextCompositionId: 1,
|
||||
nextOrderId: 1,
|
||||
compositions: [],
|
||||
compositionComponents: [],
|
||||
productionOrders: []
|
||||
};
|
||||
|
||||
const query = async (sql, params = []) => {
|
||||
const normalizedSql = String(sql).replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (/^(BEGIN|COMMIT|ROLLBACK);?$/.test(normalizedSql)) return { rows: [] };
|
||||
|
||||
if (normalizedSql.startsWith('SELECT id FROM product_compositions WHERE source')) {
|
||||
const composition = state.compositions.find(item => item.source === params[0] && item.finished_product_identity === params[1]);
|
||||
return { rows: composition ? [{ id: composition.id }] : [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('INSERT INTO product_compositions')) {
|
||||
const composition = {
|
||||
id: state.nextCompositionId++,
|
||||
source: params[0],
|
||||
external_source_id: params[1],
|
||||
finished_product_identity: params[2],
|
||||
finished_product_sku: params[3],
|
||||
finished_product_description: params[4],
|
||||
finished_product_unit: params[5],
|
||||
finished_tiny_product_id: params[6],
|
||||
source_metadata: JSON.parse(params[7]),
|
||||
last_synced_at: '2026-07-29T12:00:00.000Z'
|
||||
};
|
||||
state.compositions.push(composition);
|
||||
return { rows: [{ id: composition.id }] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('UPDATE product_compositions')) {
|
||||
const composition = state.compositions.find(item => item.id === params[7]);
|
||||
composition.external_source_id = params[0];
|
||||
composition.finished_product_sku = params[2];
|
||||
composition.finished_product_description = params[3];
|
||||
composition.finished_product_unit = params[4];
|
||||
composition.finished_tiny_product_id = params[5];
|
||||
composition.source_metadata = JSON.parse(params[6]);
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('DELETE FROM product_composition_components')) {
|
||||
state.compositionComponents = state.compositionComponents.filter(item => item.product_composition_id !== params[0]);
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('INSERT INTO product_composition_components')) {
|
||||
state.compositionComponents.push({
|
||||
id: state.compositionComponents.length + 1,
|
||||
product_composition_id: params[0],
|
||||
component_identity: params[1],
|
||||
component_tiny_id: params[2],
|
||||
component_sku: params[3],
|
||||
component_name: params[4],
|
||||
quantity_per_unit: params[5],
|
||||
unit: params[6]
|
||||
});
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.includes('FROM product_compositions composition')) {
|
||||
const compositions = state.compositions.filter(item => (
|
||||
item.source === params[0]
|
||||
&& (params[1] === '' || item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase())
|
||||
));
|
||||
return {
|
||||
rows: compositions.map(composition => ({
|
||||
...composition,
|
||||
components: state.compositionComponents
|
||||
.filter(item => item.product_composition_id === composition.id)
|
||||
.map(item => ({ ...item, component_product_id: null }))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('SELECT id FROM production_orders')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('INSERT INTO production_orders')) {
|
||||
const order = {
|
||||
id: state.nextOrderId++,
|
||||
tiny_id: params[0], number: params[1], status: params[2], order_reference: params[3],
|
||||
issue_date: params[4], expected_date: params[5], product_sku: params[6],
|
||||
product_description: params[7], quantity: params[8], unit: params[9],
|
||||
integration_status: params[10], notes: params[11], supplier: params[12], lot_code: params[13],
|
||||
roll_quantity: params[14], fabric_kg: params[15], rib_kg: params[16],
|
||||
yield_pieces_per_kg: params[17], created_at: null, updated_at: null
|
||||
};
|
||||
state.productionOrders.push(order);
|
||||
return { rows: [{ id: order.id }] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('DELETE FROM production_order_components') || normalizedSql.startsWith('DELETE FROM production_order_steps')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.includes('FROM production_orders po') && normalizedSql.includes('WHERE po.id = $1')) {
|
||||
const order = state.productionOrders.find(item => item.id === params[0]);
|
||||
return { rows: order ? [{ ...order, markers: [], components: [], steps: [] }] : [] };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected SQL in test double: ${normalizedSql.slice(0, 100)}`);
|
||||
};
|
||||
|
||||
pool.query = query;
|
||||
pool.connect = async () => ({ query, release() {} });
|
||||
try {
|
||||
await run(state);
|
||||
} finally {
|
||||
pool.connect = originalConnect;
|
||||
pool.query = originalQuery;
|
||||
}
|
||||
};
|
||||
|
||||
test('Tiny/Olist V3 first import creates a composition without creating an OP', async () => {
|
||||
await withFakeDatabase(async (state) => {
|
||||
const response = await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||
const result = response.body;
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(result.componentCount, 2);
|
||||
assert.equal(state.compositions.length, 1);
|
||||
assert.equal(state.compositionComponents.length, 2);
|
||||
assert.equal(state.productionOrders.length, 0);
|
||||
assert.equal(result.composition.finishedTinyProductId, '976058813');
|
||||
});
|
||||
});
|
||||
|
||||
test('Tiny/Olist V3 re-sync replaces composition components without duplicates', async () => {
|
||||
await withFakeDatabase(async (state) => {
|
||||
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||
await invokeHandler(tinySyncHandler, { body: compositionPayload([
|
||||
{
|
||||
componentTinyId: '976059144', componentName: 'MALHA ATUALIZADA', componentSku: 'MC.30.CAF',
|
||||
quantityPerUnit: '0.2', totalQuantity: '0.2', unit: 'KG'
|
||||
},
|
||||
{
|
||||
componentTinyId: '976059144', componentName: 'MALHA ATUALIZADA', componentSku: 'MC.30.CAF',
|
||||
quantityPerUnit: '0.2', totalQuantity: '0.2', unit: 'KG'
|
||||
}
|
||||
]) });
|
||||
|
||||
assert.equal(state.compositions.length, 1);
|
||||
assert.equal(state.compositionComponents.length, 1);
|
||||
assert.equal(state.compositionComponents[0].quantity_per_unit, 0.2);
|
||||
assert.equal(state.productionOrders.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('product composition detail lookup returns stored components', async () => {
|
||||
await withFakeDatabase(async () => {
|
||||
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||
const response = await invokeHandler(productCompositionHandler, { params: { productId: '976058813' } });
|
||||
const composition = response.body.composition;
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(composition.finishedProductSku, 'BLCS.CAF.GG');
|
||||
assert.equal(composition.components.length, 2);
|
||||
assert.deepEqual(composition.components[0], {
|
||||
id: 1,
|
||||
componentTinyId: '919498232',
|
||||
componentSku: 'ETIQ.T.GG',
|
||||
componentName: 'ETIQUETA DE TAMANHO GG',
|
||||
quantityPerUnit: 1,
|
||||
unit: 'UN',
|
||||
productId: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('product composition export endpoint returns every stored composition', async () => {
|
||||
await withFakeDatabase(async () => {
|
||||
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||
const response = await invokeHandler(productCompositionsHandler, { params: {} });
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.compositions.length, 1);
|
||||
assert.equal(response.body.compositions[0].components.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('ordinary Tiny production-order payloads continue to create production orders', async () => {
|
||||
await withFakeDatabase(async (state) => {
|
||||
const response = await invokeHandler(tinySyncHandler, { body: {
|
||||
order: {
|
||||
tinyId: '12345', number: 'OP-12345', status: 'in_progress', productSku: 'BLCS.CAF.GG',
|
||||
productDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG', quantity: '10', unit: 'UN'
|
||||
},
|
||||
components: [],
|
||||
steps: []
|
||||
} });
|
||||
const result = response.body;
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(state.compositions.length, 0);
|
||||
assert.equal(state.productionOrders.length, 1);
|
||||
assert.equal(result.order.tinyId, '12345');
|
||||
assert.equal(result.order.number, 'OP-12345');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,11 @@ services:
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
|
||||
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
|
||||
- TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY:-}
|
||||
- TURNSTILE_SITEKEY=${TURNSTILE_SITEKEY:-}
|
||||
- VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY:-}
|
||||
- TURNSTILE_SECRET=${TURNSTILE_SECRET:-}
|
||||
- TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY:-}
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
@@ -37,7 +42,7 @@ services:
|
||||
image: gitea.blyzer.com.br/blyzer/graphs-frontend:latest
|
||||
container_name: graph_frontend
|
||||
ports:
|
||||
- "3005:80"
|
||||
- "3002:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en" class="dark" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
19
src/App.tsx
19
src/App.tsx
@@ -7,10 +7,17 @@ import { isAuthenticated, isSuperAdmin } 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 Supplies = React.lazy(() => import('./pages/Supplies'));
|
||||
const Clients = React.lazy(() => import('./pages/Clients'));
|
||||
const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
|
||||
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'));
|
||||
|
||||
@@ -44,11 +51,21 @@ function App() {
|
||||
<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="clients" element={<Clients />} />
|
||||
<Route path="clients/:name" element={<ClientDetails />} />
|
||||
<Route path="clients/:clientToken" element={<ClientDetails />} />
|
||||
<Route path="rfm" element={<Rfm />} />
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
<Route path="registrations" element={<Registrations />} />
|
||||
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
119
src/analytics/clients.test.ts
Normal file
119
src/analytics/clients.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { DateRange, OrderData } from '../types';
|
||||
import { parseOrderDate } from '../dataService';
|
||||
import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue } from './orders';
|
||||
import type { DateRange, OrderData } from '../types.ts';
|
||||
import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue, getOrderCustomerKey, parseOrderDate } from './orders.ts';
|
||||
|
||||
export type ClientSortOption =
|
||||
| 'recent'
|
||||
@@ -13,6 +12,8 @@ export type ClientSortOption =
|
||||
| 'items_asc';
|
||||
|
||||
export interface ClientSummary {
|
||||
customerKey: string;
|
||||
clientToken: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
totalSpent: number;
|
||||
@@ -37,8 +38,17 @@ export interface ClientDetailsMetrics {
|
||||
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;
|
||||
@@ -47,6 +57,22 @@ export interface ClientDetailsMetrics {
|
||||
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;
|
||||
@@ -97,6 +123,7 @@ const enrichClientsWithRfmType = (
|
||||
|
||||
return {
|
||||
...client,
|
||||
clientToken: client.clientToken || client.customerKey,
|
||||
averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0,
|
||||
clientType: getClientType(recencyScore, valueScore),
|
||||
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
|
||||
@@ -113,6 +140,7 @@ export const buildClientsSummary = (
|
||||
): ClientSummary[] => {
|
||||
const orders = filterOrdersByDateRange(ordersData, dateRange);
|
||||
const clientMap: Record<string, {
|
||||
name: string;
|
||||
totalSpent: number;
|
||||
totalItems: number;
|
||||
uniqueOrders: Set<string>;
|
||||
@@ -122,33 +150,36 @@ export const buildClientsSummary = (
|
||||
|
||||
orders.forEach(order => {
|
||||
const clientName = getClientDisplayName(order);
|
||||
const customerKey = getOrderCustomerKey(order);
|
||||
|
||||
if (!clientMap[clientName]) {
|
||||
clientMap[clientName] = { totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' };
|
||||
if (!clientMap[customerKey]) {
|
||||
clientMap[customerKey] = { name: clientName, totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' };
|
||||
}
|
||||
|
||||
if (order.Fone_Cliente) {
|
||||
clientMap[clientName].phone = order.Fone_Cliente;
|
||||
clientMap[customerKey].phone = order.Fone_Cliente;
|
||||
}
|
||||
|
||||
clientMap[clientName].totalSpent += getOrderItemRevenue(order);
|
||||
clientMap[clientName].totalItems += order.Quantidade;
|
||||
clientMap[clientName].uniqueOrders.add(`${order.Data_Pedido}_${order.Valor_Pedido}`);
|
||||
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[clientName].lastPurchase) {
|
||||
clientMap[clientName].lastPurchase = orderTime;
|
||||
if (orderTime > clientMap[customerKey].lastPurchase) {
|
||||
clientMap[customerKey].lastPurchase = orderTime;
|
||||
}
|
||||
});
|
||||
|
||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||
const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(name => ({
|
||||
name,
|
||||
phone: clientMap[name].phone,
|
||||
totalSpent: clientMap[name].totalSpent,
|
||||
totalItems: clientMap[name].totalItems,
|
||||
orderCount: clientMap[name].uniqueOrders.size,
|
||||
lastPurchase: clientMap[name].lastPurchase
|
||||
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
|
||||
@@ -170,17 +201,29 @@ export const buildClientsSummary = (
|
||||
});
|
||||
};
|
||||
|
||||
export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: string, dateRange: DateRange): ClientDetailsMetrics => {
|
||||
const clientOrders = ordersData.filter(order => getClientDisplayName(order) === clientName);
|
||||
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 => {
|
||||
@@ -193,11 +236,12 @@ export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: s
|
||||
groupedOrdersMap[key] = {
|
||||
date: order.Data_Pedido,
|
||||
orderId: order.ID_Pedido || key,
|
||||
orderTotal: order.Valor_Pedido,
|
||||
orderTotal: 0,
|
||||
items: []
|
||||
};
|
||||
}
|
||||
groupedOrdersMap[key].items.push(order);
|
||||
groupedOrdersMap[key].orderTotal += getOrderItemRevenue(order);
|
||||
});
|
||||
|
||||
const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => {
|
||||
@@ -210,11 +254,39 @@ export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: s
|
||||
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,
|
||||
|
||||
177
src/analytics/cutting.test.ts
Normal file
177
src/analytics/cutting.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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, 'BLPM');
|
||||
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);
|
||||
});
|
||||
303
src/analytics/cutting.ts
Normal file
303
src/analytics/cutting.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
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: 'BLPM',
|
||||
label: 'Moletom',
|
||||
materialLabel: 'BLPM',
|
||||
keywords: ['MOLETOM'],
|
||||
unitsPerRoll: null
|
||||
},
|
||||
{
|
||||
key: 'BLOS',
|
||||
label: 'Camiseta over',
|
||||
materialLabel: 'BLOS',
|
||||
keywords: ['OVER'],
|
||||
unitsPerRoll: null
|
||||
},
|
||||
{
|
||||
key: 'BLMC',
|
||||
label: 'Camiseta infantil',
|
||||
materialLabel: 'BLMC',
|
||||
keywords: ['INFANTIL', 'KIDS'],
|
||||
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 === 'in_progress' ||
|
||||
['em aberto', 'aberta', 'andamento', 'em andamento'].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)
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { DashboardAnalytics, DateRange, OrderData } from '../types';
|
||||
import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue } from './orders';
|
||||
import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue, parseOrderDate } from './orders';
|
||||
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
|
||||
import { normalizeUnknownLabel } from '../chartUtils';
|
||||
|
||||
const COLORS = [
|
||||
'#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316',
|
||||
'#06b6d4', '#ec4899', '#eab308', '#6366f1', '#14b8a6',
|
||||
'#6ee7b7', '#93c5fd', '#c4b5fd', '#fda4af', '#fdba74',
|
||||
'#67e8f9', '#f9a8d4', '#fde047', '#a5b4fc', '#5eead4'
|
||||
'#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> = {};
|
||||
@@ -19,6 +21,10 @@ const getProductColor = (name: string): string => {
|
||||
return globalColorMap[name];
|
||||
};
|
||||
|
||||
const formatSellerDisplayName = (value: string) => (
|
||||
normalizeUnknownLabel(formatDisplayName(removeTrailingSellerId(value)), 'Sem vendedor')
|
||||
);
|
||||
|
||||
export interface ChartProductMetric {
|
||||
name: string;
|
||||
id: string;
|
||||
@@ -26,18 +32,36 @@ export interface ChartProductMetric {
|
||||
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.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);
|
||||
@@ -55,6 +79,28 @@ export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetr
|
||||
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]
|
||||
}))
|
||||
};
|
||||
};
|
||||
@@ -66,20 +112,58 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa
|
||||
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);
|
||||
@@ -100,11 +184,56 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa
|
||||
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
|
||||
revenueByProduct,
|
||||
revenueBySeller,
|
||||
ordersBySeller,
|
||||
sellerRevenueByDate,
|
||||
sellerRevenueByHour
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import type { DateRange, OrderData } from '../types';
|
||||
import { parseOrderDate } from '../dataService';
|
||||
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')) {
|
||||
@@ -13,7 +26,11 @@ export const getBaseProductName = (description: string): string => {
|
||||
};
|
||||
|
||||
export const getClientDisplayName = (order: OrderData): string => {
|
||||
return order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`;
|
||||
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 => {
|
||||
|
||||
26
src/catalogLinks.ts
Normal file
26
src/catalogLinks.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
type SkuEditParams = {
|
||||
sku: string;
|
||||
name?: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
};
|
||||
|
||||
export const buildSkuEditPath = ({ sku, name = '', color = '', size = '' }: 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);
|
||||
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()}`;
|
||||
};
|
||||
125
src/chartUtils.ts
Normal file
125
src/chartUtils.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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;
|
||||
};
|
||||
34
src/components/BackButton.tsx
Normal file
34
src/components/BackButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Calendar, RefreshCw, ChevronDown } from 'lucide-react';
|
||||
import type { DateRange } from '../types';
|
||||
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
|
||||
@@ -32,10 +32,18 @@ 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' });
|
||||
@@ -49,8 +57,8 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
|
||||
const newStart = parseLocalDateInput(e.target.value);
|
||||
if (newStart && !isNaN(newStart.getTime())) {
|
||||
const start = startOfLocalDay(newStart);
|
||||
const end = dateRange.end < start ? endOfLocalDay(start) : dateRange.end;
|
||||
onChange({ start, end });
|
||||
const end = draftRange.end < start ? endOfLocalDay(start) : draftRange.end;
|
||||
setCustomDraft({ appliedKey: appliedRangeKey, range: { start, end } });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,11 +66,19 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
|
||||
const newEnd = parseLocalDateInput(e.target.value);
|
||||
if (newEnd && !isNaN(newEnd.getTime())) {
|
||||
const end = endOfLocalDay(newEnd);
|
||||
const start = dateRange.start > end ? startOfLocalDay(end) : dateRange.start;
|
||||
onChange({ start, end });
|
||||
const start = draftRange.start > end ? startOfLocalDay(end) : draftRange.start;
|
||||
setCustomDraft({ appliedKey: appliedRangeKey, range: { start, end } });
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
@@ -103,11 +119,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(dateRange.start)}</span>
|
||||
<span className="w-full text-center">{formatDisplayDate(draftRange.start)}</span>
|
||||
<input
|
||||
ref={startRef}
|
||||
type="date"
|
||||
value={formatDateForInput(dateRange.start)}
|
||||
value={formatDateForInput(draftRange.start)}
|
||||
onChange={handleStartChange}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
@@ -119,16 +135,24 @@ 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(dateRange.end)}</span>
|
||||
<span className="w-full text-center">{formatDisplayDate(draftRange.end)}</span>
|
||||
<input
|
||||
ref={endRef}
|
||||
type="date"
|
||||
value={formatDateForInput(dateRange.end)}
|
||||
value={formatDateForInput(draftRange.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>
|
||||
@@ -136,7 +160,9 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={() => {
|
||||
onChange(preset.getRange());
|
||||
const nextRange = preset.getRange();
|
||||
setCustomDraft({ appliedKey: getRangeKey(nextRange), range: nextRange });
|
||||
onChange(nextRange);
|
||||
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"
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone, Grid3X3, Shield } from 'lucide-react';
|
||||
import type { DateRange, OrderData, StockData } from '../types';
|
||||
import { fetchData, fetchStock, isSuperAdmin, logout } from '../dataService';
|
||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList } from 'lucide-react';
|
||||
import type { DateRange, OrderData } from '../types';
|
||||
import { isSuperAdmin, logout } from '../dataService';
|
||||
import { rangeForLastDays } from '../dateRanges';
|
||||
|
||||
const emptyOrdersData: OrderData[] = [];
|
||||
type ThemeMode = 'dark' | 'offwhite';
|
||||
|
||||
const Layout = () => {
|
||||
const location = useLocation();
|
||||
const needsRawData = location.pathname.startsWith('/products') ||
|
||||
(location.pathname.startsWith('/clients/') && location.pathname !== '/clients');
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
||||
});
|
||||
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');
|
||||
@@ -24,42 +28,16 @@ const Layout = () => {
|
||||
return rangeForLastDays(30);
|
||||
});
|
||||
|
||||
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
|
||||
const [stockData, setStockData] = useState<StockData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(needsRawData);
|
||||
const [refreshInterval, setRefreshInterval] = useState<number>(() => {
|
||||
const saved = localStorage.getItem('nexstar_refresh_interval');
|
||||
return saved ? Number(saved) : 0;
|
||||
});
|
||||
|
||||
const loadData = useCallback(async (showLoading = false) => {
|
||||
if (showLoading) setIsLoading(true);
|
||||
try {
|
||||
const [data, stock] = await Promise.all([fetchData(), fetchStock()]);
|
||||
setOrdersData(data);
|
||||
setStockData(stock);
|
||||
} finally {
|
||||
if (showLoading) setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsRawData) return;
|
||||
|
||||
// Product pages and client details still depend on raw orders until their API migration is complete.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadData(true);
|
||||
}, [loadData, needsRawData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshInterval === 0 || !needsRawData) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
loadData(false);
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [loadData, needsRawData, refreshInterval]);
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.dataset.theme = themeMode;
|
||||
localStorage.setItem('nexstar_theme', themeMode);
|
||||
}, [themeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString());
|
||||
@@ -77,29 +55,47 @@ const Layout = () => {
|
||||
setIsSidebarCollapsed(newState);
|
||||
localStorage.setItem('graph_sidebar_collapsed', String(newState));
|
||||
};
|
||||
const toggleTheme = () => {
|
||||
setThemeMode(current => current === 'dark' ? 'offwhite' : 'dark');
|
||||
};
|
||||
|
||||
const appNavigation = [
|
||||
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
|
||||
{ name: 'Produtos', href: '/products', icon: Package },
|
||||
{ name: 'Clientes', href: '/clients', icon: Users },
|
||||
{ name: 'RFV', href: '/rfm', icon: Grid3X3 },
|
||||
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
|
||||
];
|
||||
const adminNavigation = isSuperAdmin()
|
||||
? [{ name: 'Usuários', href: '/admin/users', icon: Shield }]
|
||||
: [];
|
||||
const navigationSections = [
|
||||
{ label: 'Painel', items: appNavigation },
|
||||
{
|
||||
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: 'RFV', href: '/rfm', icon: Grid3X3 },
|
||||
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
|
||||
],
|
||||
},
|
||||
...(adminNavigation.length ? [{ label: 'Super admin', items: adminNavigation }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-dark-bg text-dark-text overflow-hidden">
|
||||
<div className="app-shell flex h-screen text-dark-text overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className={`bg-dark-sidebar border-r border-dark-border flex flex-col transition-all duration-300 ${isSidebarCollapsed ? 'w-20' : 'w-64'}`}>
|
||||
<div className={`h-20 px-6 border-b border-dark-border flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-1.5 bg-brand-primary/20 rounded-lg text-brand-primary`}>
|
||||
<div className="rounded-lg border border-brand-primary/25 bg-brand-primary/15 p-1.5 text-brand-primary shadow-sm shadow-brand-primary/10">
|
||||
<BarChart3 className="w-6 h-6" />
|
||||
</div>
|
||||
{!isSidebarCollapsed && <span className="text-xl font-bold text-dark-text">Nexstar</span>}
|
||||
@@ -119,11 +115,16 @@ const Layout = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="flex-1 space-y-6 overflow-y-auto p-4">
|
||||
<nav className="flex-1 space-y-5 overflow-y-auto px-3 py-4">
|
||||
{navigationSections.map((section) => (
|
||||
<div key={section.label} className="space-y-2">
|
||||
<div
|
||||
key={section.label}
|
||||
className={`space-y-1.5 ${
|
||||
isSidebarCollapsed ? 'border-t border-dark-border pt-5 first:border-t-0 first:pt-0' : ''
|
||||
}`}
|
||||
>
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="px-3 text-xs font-bold uppercase tracking-widest text-dark-muted">
|
||||
<div className="px-3 pb-1 text-[11px] font-bold uppercase tracking-widest text-dark-muted">
|
||||
{section.label}
|
||||
</div>
|
||||
)}
|
||||
@@ -133,17 +134,17 @@ const Layout = () => {
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`flex items-center rounded-xl px-4 py-3 transition-all ${
|
||||
isSidebarCollapsed ? 'justify-center' : 'space-x-3'
|
||||
className={`group flex min-h-11 items-center rounded-lg px-3 py-2.5 text-sm transition-all ${
|
||||
isSidebarCollapsed ? 'justify-center' : 'gap-3'
|
||||
} ${
|
||||
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'
|
||||
? '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-5 w-5 shrink-0" />
|
||||
{!isSidebarCollapsed && <span className="font-medium">{item.name}</span>}
|
||||
<item.icon className="h-4.5 w-4.5 shrink-0" />
|
||||
{!isSidebarCollapsed && <span className="truncate font-medium">{item.name}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -165,19 +166,22 @@ const Layout = () => {
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col h-screen overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="h-20 bg-dark-header border-b border-dark-border flex items-center px-8 shrink-0">
|
||||
<header className="h-20 bg-dark-header border-b border-dark-border flex items-center justify-between px-8 shrink-0">
|
||||
<h2 className="text-xl font-bold text-dark-text">Painel de Análise</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-transparent bg-transparent text-dark-text transition-colors hover:border-dark-border hover:bg-dark-input hover:text-brand-primary cursor-pointer"
|
||||
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>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto p-8 relative">
|
||||
{needsRawData && isLoading && (
|
||||
<div className="absolute right-8 top-8 z-10 flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||
Atualizando dados
|
||||
</div>
|
||||
)}
|
||||
<Outlet context={{ dateRange, setDateRange, ordersData, stockData, isDataLoading: needsRawData && isLoading, refreshInterval, setRefreshInterval, loadData }} />
|
||||
<div className="app-content flex-1 overflow-y-auto p-8 relative">
|
||||
<Outlet context={{ dateRange, setDateRange, ordersData: emptyOrdersData, isDataLoading: false, refreshInterval, setRefreshInterval, themeMode }} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
138
src/components/PaginationControls.tsx
Normal file
138
src/components/PaginationControls.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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'
|
||||
}: 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} 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="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;
|
||||
40
src/components/ProductColorBadge.tsx
Normal file
40
src/components/ProductColorBadge.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
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;
|
||||
21
src/components/ProductTypeBadge.tsx
Normal file
21
src/components/ProductTypeBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getProductTypeConfig, type ProductTypeKey } from '../productClassification';
|
||||
|
||||
type ProductTypeBadgeProps = {
|
||||
type: ProductTypeKey;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ProductTypeBadge = ({ type, className = '' }: ProductTypeBadgeProps) => {
|
||||
const config = getProductTypeConfig(type);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex w-fit items-center whitespace-nowrap rounded-full border px-2 py-0.5 text-[10px] font-bold ${config.badgeClassName} ${className}`}
|
||||
title={config.description}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductTypeBadge;
|
||||
18
src/components/RefreshStatus.tsx
Normal file
18
src/components/RefreshStatus.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
201
src/components/SkuPlanningModal.tsx
Normal file
201
src/components/SkuPlanningModal.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import { RotateCcw, Save, X } from 'lucide-react';
|
||||
import { CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting';
|
||||
import { editableProductTypeOptions, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
import type { CutProductOverride } from '../types';
|
||||
import ProductTypeBadge from './ProductTypeBadge';
|
||||
|
||||
type SkuPlanningModalProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
};
|
||||
|
||||
type SkuPlanningModalProps = {
|
||||
product: SkuPlanningModalProduct;
|
||||
override?: CutProductOverride;
|
||||
isSaving?: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (override: CutProductOverride | null) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const familyOptions: Array<{ value: CutFamilyKey | ''; label: string }> = [
|
||||
{ value: '', label: 'Auto' },
|
||||
...CUT_FAMILY_RULES.map(rule => ({ value: rule.key, label: `${rule.materialLabel} · ${rule.label}` })),
|
||||
{ value: 'OUTROS', label: 'Sem regra' }
|
||||
];
|
||||
|
||||
const buildOverride = ({
|
||||
familyKey,
|
||||
color,
|
||||
size,
|
||||
productType,
|
||||
planningNotes
|
||||
}: Required<Pick<CutProductOverride, 'color' | 'size' | 'planningNotes'>> & {
|
||||
familyKey: CutFamilyKey | '';
|
||||
productType: ProductTypeKey | '';
|
||||
}): CutProductOverride | null => {
|
||||
const next: CutProductOverride = {
|
||||
familyKey,
|
||||
color: color.trim(),
|
||||
size: size.trim().toUpperCase(),
|
||||
productType,
|
||||
planningNotes: planningNotes.trim()
|
||||
};
|
||||
|
||||
if (!next.familyKey && !next.color && !next.size && !next.productType && !next.planningNotes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const SkuPlanningModal = ({ product, override, isSaving = false, onClose, onSave }: SkuPlanningModalProps) => {
|
||||
const [familyKey, setFamilyKey] = useState<CutFamilyKey | ''>(override?.familyKey || '');
|
||||
const [color, setColor] = useState(override?.color || '');
|
||||
const [size, setSize] = useState(override?.size || '');
|
||||
const [productType, setProductType] = useState<ProductTypeKey | ''>(override?.productType || '');
|
||||
const [planningNotes, setPlanningNotes] = useState(override?.planningNotes || '');
|
||||
|
||||
const resolvedType = resolveProductType(product.name, { productType });
|
||||
const resolvedConfig = getProductTypeConfig(resolvedType);
|
||||
|
||||
const handleSave = () => {
|
||||
void onSave(buildOverride({ familyKey, color, size, productType, planningNotes }));
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
void onSave(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex h-dvh items-center justify-center overflow-y-auto bg-zinc-950/80 p-4 backdrop-blur-md dark:bg-black/75"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Editar planejamento do SKU ${product.id}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl rounded-2xl border border-dark-border bg-dark-card shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-dark-border p-5">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-[10px] font-bold uppercase tracking-widest text-dark-muted">SKU #{product.id}</div>
|
||||
<h2 className="mt-1 truncate text-lg font-bold text-dark-text" title={product.name}>{product.name}</h2>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ProductTypeBadge type={resolvedType} />
|
||||
<span className="text-xs font-semibold text-dark-muted">{resolvedConfig.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"
|
||||
title="Fechar"
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 p-5 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Tipo de produto</span>
|
||||
<select
|
||||
value={productType}
|
||||
onChange={(event) => setProductType(event.target.value as ProductTypeKey | '')}
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
<option value="">Auto ({getProductTypeConfig(resolveProductType(product.name)).label})</option>
|
||||
{editableProductTypeOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Família de corte</span>
|
||||
<select
|
||||
value={familyKey}
|
||||
onChange={(event) => setFamilyKey(event.target.value as CutFamilyKey | '')}
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
{familyOptions.map(option => (
|
||||
<option key={option.value || 'auto'} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Cor</span>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(event) => setColor(event.target.value)}
|
||||
placeholder={product.color || 'Auto pelo nome'}
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Tamanho</span>
|
||||
<input
|
||||
type="text"
|
||||
value={size}
|
||||
onChange={(event) => setSize(event.target.value)}
|
||||
placeholder={product.size || 'Auto pelo nome'}
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold uppercase text-dark-text outline-none transition-colors placeholder:normal-case placeholder:text-dark-muted focus:border-brand-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Notas de planejamento</span>
|
||||
<textarea
|
||||
value={planningNotes}
|
||||
onChange={(event) => setPlanningNotes(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="Ex.: revisar consumo, material usado, regra temporária..."
|
||||
className="w-full resize-none rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-dark-border p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={isSaving}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-4 py-2.5 text-sm font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Limpar override
|
||||
</button>
|
||||
<div className="flex gap-2 sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="inline-flex flex-1 items-center justify-center rounded-xl border border-dark-border bg-dark-input px-4 py-2.5 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2.5 text-sm font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none cursor-pointer"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? 'Salvando' : 'Salvar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkuPlanningModal;
|
||||
@@ -1,29 +1,96 @@
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, RfmAnalytics, StockData } from './types';
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
const ANALYTICS_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const IN_FLIGHT_CACHE_TTL_MS = 15 * 1000;
|
||||
|
||||
export const login = async (email: string, password: string): Promise<boolean> => {
|
||||
type CacheOptions = {
|
||||
force?: boolean;
|
||||
shouldCache?: (data: unknown) => boolean;
|
||||
};
|
||||
|
||||
type ApiCacheEntry<T> = {
|
||||
expiresAt: number;
|
||||
data?: T;
|
||||
promise?: Promise<T>;
|
||||
};
|
||||
|
||||
const analyticsCache = new Map<string, ApiCacheEntry<unknown>>();
|
||||
|
||||
const getCachedAnalyticsValue = <T>(key: string): T | undefined => {
|
||||
const entry = analyticsCache.get(key) as ApiCacheEntry<T> | undefined;
|
||||
if (!entry || entry.data === undefined || entry.expiresAt <= Date.now()) return undefined;
|
||||
return entry.data;
|
||||
};
|
||||
|
||||
const getCachedAnalytics = async <T>(key: string, loader: () => Promise<T>, options: CacheOptions = {}): Promise<T> => {
|
||||
const now = Date.now();
|
||||
const cached = analyticsCache.get(key) as ApiCacheEntry<T> | undefined;
|
||||
|
||||
if (!options.force) {
|
||||
if (cached?.data !== undefined && cached.expiresAt > now) return cached.data;
|
||||
if (cached?.promise && cached.expiresAt > now) return cached.promise;
|
||||
}
|
||||
|
||||
const promise = loader();
|
||||
analyticsCache.set(key, { expiresAt: now + IN_FLIGHT_CACHE_TTL_MS, promise });
|
||||
|
||||
try {
|
||||
const data = await promise;
|
||||
if (data === null || options.shouldCache?.(data) === false) {
|
||||
analyticsCache.delete(key);
|
||||
} else {
|
||||
analyticsCache.set(key, { data, expiresAt: Date.now() + ANALYTICS_CACHE_TTL_MS });
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
analyticsCache.delete(key);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({
|
||||
start: formatDateParam(dateRange.start),
|
||||
end: formatDateParam(dateRange.end)
|
||||
});
|
||||
|
||||
export type LoginConfig = {
|
||||
captchaRequired: boolean;
|
||||
captchaConfigured: boolean;
|
||||
turnstileSiteKey: string;
|
||||
};
|
||||
|
||||
export type LoginResult = 'success' | 'invalid_credentials' | 'captcha_failed' | 'server_error';
|
||||
|
||||
export const getLoginConfig = async (): Promise<LoginConfig> => {
|
||||
const response = await fetch(`${API_URL}/login/config`, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Failed to load login config');
|
||||
return response.json() as Promise<LoginConfig>;
|
||||
};
|
||||
|
||||
export const login = async (email: string, password: string, captchaToken?: string): Promise<LoginResult> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email, password, captchaToken }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { token?: string; user?: AuthUser };
|
||||
if (!data.token || !data.user) return false;
|
||||
if (!data.token || !data.user) return 'server_error';
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user));
|
||||
return true;
|
||||
return 'success';
|
||||
}
|
||||
return false;
|
||||
if (response.status === 403) return 'captcha_failed';
|
||||
return 'invalid_credentials';
|
||||
} catch (error) {
|
||||
console.error('Login failed', error);
|
||||
return false;
|
||||
return 'server_error';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -109,165 +176,530 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
|
||||
return response;
|
||||
};
|
||||
|
||||
const rfmSegmentLabels: Record<string, string> = {
|
||||
champions: 'Champions',
|
||||
potential_loyalists: 'Potenciais Leais',
|
||||
new_customers: 'Novos Clientes',
|
||||
loyal_customers: 'Clientes Leais',
|
||||
need_attention: 'Precisam de Atenção',
|
||||
about_to_sleep: 'Quase Dormindo',
|
||||
at_risk: 'Em Risco',
|
||||
hibernating: 'Hibernando',
|
||||
lost: 'Perdidos'
|
||||
export const downloadDatabaseDiagnostic = async (): Promise<void> => {
|
||||
const response = await authFetch('/admin/database-diagnostic', { cache: 'no-store' });
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível exportar o diagnóstico do banco.');
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `graphs-db-diagnostic-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const rfmSegmentByScore: Record<string, string> = {
|
||||
'3-3': 'champions',
|
||||
'3-2': 'potential_loyalists',
|
||||
'3-1': 'new_customers',
|
||||
'2-3': 'loyal_customers',
|
||||
'2-2': 'need_attention',
|
||||
'2-1': 'about_to_sleep',
|
||||
'1-3': 'at_risk',
|
||||
'1-2': 'hibernating',
|
||||
'1-1': 'lost'
|
||||
export const fetchProductionOrders = async (
|
||||
dateRange: DateRange,
|
||||
filters?: { search?: string },
|
||||
options?: CacheOptions
|
||||
): Promise<ProductionOrderSummary> => {
|
||||
const params = buildDateRangeParams(dateRange);
|
||||
const search = filters?.search?.trim();
|
||||
if (search) params.set('search', search);
|
||||
|
||||
const path = `/production-orders?${params.toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
try {
|
||||
const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {});
|
||||
if (!response.ok) return { orders: [], counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 } };
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch production orders failed', error);
|
||||
return { orders: [], counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 } };
|
||||
}
|
||||
}, options);
|
||||
};
|
||||
|
||||
const scoreTertile = (value: number, values: number[], higherIsBetter = true): 1 | 2 | 3 => {
|
||||
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 higherIsBetter ? 2 : 3;
|
||||
|
||||
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)) as 1 | 2 | 3;
|
||||
};
|
||||
|
||||
const fetchClientAnalyticsForRange = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => {
|
||||
const params = new URLSearchParams({
|
||||
start: formatDateParam(dateRange.start),
|
||||
end: formatDateParam(dateRange.end)
|
||||
export const createProductionOrders = async (orders: ProductionOrderPayload[]): Promise<CreateProductionOrdersResult> => {
|
||||
const response = await authFetch('/production-orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orders })
|
||||
});
|
||||
const response = await authFetch(`/analytics/clients?${params.toString()}`);
|
||||
if (!response.ok) return [];
|
||||
|
||||
if (!response.ok) throw new Error('Não foi possível criar as ordens de produção.');
|
||||
analyticsCache.clear();
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
const buildFallbackRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalytics | null> => {
|
||||
let clientRows: ClientAnalyticsItem[];
|
||||
export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => {
|
||||
const response = await authFetch(`/production-orders/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Não foi possível atualizar o status da ordem.');
|
||||
analyticsCache.clear();
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
|
||||
try {
|
||||
clientRows = await fetchClientAnalyticsForRange(dateRange);
|
||||
const response = await authFetch('/cutting-settings');
|
||||
if (!response.ok) return { familyYields: {}, productOverrides: {} };
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch fallback RFM analytics failed', error);
|
||||
return null;
|
||||
console.error('Fetch cutting settings failed', error);
|
||||
return { familyYields: {}, productOverrides: {} };
|
||||
}
|
||||
};
|
||||
|
||||
export const saveCuttingSettings = async (settings: CuttingSettings): Promise<CuttingSettings> => {
|
||||
const response = await authFetch('/cutting-settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível salvar as regras de corte.');
|
||||
}
|
||||
|
||||
if (!clientRows.length) return null;
|
||||
return data as CuttingSettings;
|
||||
};
|
||||
|
||||
const rangeEndTime = dateRange.end.getTime();
|
||||
const recencyValues = clientRows.map(client => {
|
||||
const lastPurchaseTime = client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : rangeEndTime;
|
||||
return Math.max(0, Math.floor((rangeEndTime - lastPurchaseTime) / 86400000));
|
||||
});
|
||||
const frequencyValues = clientRows.map(client => client.orderCount);
|
||||
const monetaryValues = clientRows.map(client => client.totalSpent);
|
||||
export const fetchCatalogSummary = async (): Promise<CatalogSummary> => {
|
||||
try {
|
||||
const response = await authFetch('/catalog');
|
||||
if (!response.ok) return { categories: [], products: [], consumptionReferences: [] };
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch catalog summary failed', error);
|
||||
return { categories: [], products: [], consumptionReferences: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const clients = clientRows.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))) as 1 | 2 | 3;
|
||||
const segmentKey = rfmSegmentByScore[`${recencyScore}-${valueScore}`] || 'lost';
|
||||
|
||||
return {
|
||||
customerKey: client.customerKey,
|
||||
name: client.name,
|
||||
phone: client.phone,
|
||||
monetary: client.totalSpent,
|
||||
frequency: client.orderCount,
|
||||
quantityPurchased: client.quantityPurchased,
|
||||
lastPurchaseDate: client.lastPurchaseDate,
|
||||
recencyDays: recencyValues[index],
|
||||
recencyScore,
|
||||
frequencyScore,
|
||||
monetaryScore,
|
||||
valueScore,
|
||||
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
|
||||
segmentKey,
|
||||
segmentLabel: rfmSegmentLabels[segmentKey] || 'Perdidos'
|
||||
};
|
||||
export const saveCatalogCategory = async (payload: CatalogCategoryPayload): Promise<CatalogCategory> => {
|
||||
const response = await authFetch('/catalog/categories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const segments = Object.entries(rfmSegmentLabels).map(([key, label]) => {
|
||||
const segmentClients = clients.filter(client => client.segmentKey === key);
|
||||
const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0);
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível salvar a categoria.');
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
count: segmentClients.length,
|
||||
totalRevenue,
|
||||
averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0
|
||||
};
|
||||
return data as CatalogCategory;
|
||||
};
|
||||
|
||||
export const deleteCatalogCategory = async (id: number): Promise<void> => {
|
||||
const response = await authFetch(`/catalog/categories/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Não foi possível excluir a categoria.');
|
||||
}
|
||||
};
|
||||
|
||||
export const saveCatalogProduct = async (payload: CatalogProductPayload): Promise<CatalogProduct> => {
|
||||
const response = await authFetch('/catalog/products', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
return {
|
||||
range: {
|
||||
start: formatDateParam(dateRange.start),
|
||||
end: formatDateParam(dateRange.end)
|
||||
},
|
||||
clients,
|
||||
segments,
|
||||
matrix: {
|
||||
recencyScores: [3, 2, 1],
|
||||
valueScores: [1, 2, 3]
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível salvar o produto.');
|
||||
}
|
||||
|
||||
return data as CatalogProduct;
|
||||
};
|
||||
|
||||
export const deleteCatalogProduct = async (id: number): Promise<void> => {
|
||||
const response = await authFetch(`/catalog/products/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Não foi possível excluir o produto.');
|
||||
}
|
||||
};
|
||||
|
||||
export const saveConsumptionReference = async (payload: ConsumptionReferencePayload): Promise<ConsumptionReference> => {
|
||||
const response = await authFetch('/catalog/consumption-references', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível salvar a referência de consumo.');
|
||||
}
|
||||
|
||||
return data as ConsumptionReference;
|
||||
};
|
||||
|
||||
export const deleteConsumptionReference = async (id: number): Promise<void> => {
|
||||
const response = await authFetch(`/catalog/consumption-references/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Não foi possível excluir a referência.');
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchSupplySummary = async (): Promise<SupplySummary> => {
|
||||
const emptySummary: SupplySummary = {
|
||||
receipts: [],
|
||||
lots: [],
|
||||
movements: [],
|
||||
fabricPlans: [],
|
||||
purchaseNeeds: [],
|
||||
stats: {
|
||||
totalQuantityKg: 0,
|
||||
activeLots: 0,
|
||||
rolls: 0,
|
||||
alerts: 0,
|
||||
pendingReceipts: 0,
|
||||
approvedReceipts: 0
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<DashboardAnalytics | null> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
start: formatDateParam(dateRange.start),
|
||||
end: formatDateParam(dateRange.end)
|
||||
});
|
||||
const response = await authFetch(`/analytics/dashboard?${params.toString()}`);
|
||||
if (!response.ok) return null;
|
||||
const response = await authFetch('/supply');
|
||||
if (!response.ok) return emptySummary;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch dashboard analytics failed', error);
|
||||
console.error('Fetch supply summary failed', error);
|
||||
return emptySummary;
|
||||
}
|
||||
};
|
||||
|
||||
export const createSupplyReceipt = async (payload: SupplyReceiptPayload): Promise<SupplyReceipt> => {
|
||||
const response = await authFetch('/supply/receipts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível registrar o recebimento.');
|
||||
}
|
||||
|
||||
return data as SupplyReceipt;
|
||||
};
|
||||
|
||||
export const approveSupplyReceipt = async (id: number): Promise<SupplyReceipt> => {
|
||||
const response = await authFetch(`/supply/receipts/${id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível aprovar o recebimento.');
|
||||
}
|
||||
|
||||
return data as SupplyReceipt;
|
||||
};
|
||||
|
||||
export const deleteSupplyReceipt = async (id: number): Promise<void> => {
|
||||
const response = await authFetch(`/supply/receipts/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Não foi possível remover o recebimento.');
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchSupplyFabricPlans = async (): Promise<SupplyFabricPlan[]> => {
|
||||
try {
|
||||
const response = await authFetch('/supply/fabric-plans');
|
||||
if (!response.ok) return [];
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch supply fabric plans failed', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const createSupplyFabricPlan = async (payload: SupplyFabricPlanPayload): Promise<SupplyFabricPlan> => {
|
||||
const response = await authFetch('/supply/fabric-plans', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível salvar o plano de malha.');
|
||||
}
|
||||
|
||||
return data as SupplyFabricPlan;
|
||||
};
|
||||
|
||||
export const deleteSupplyFabricPlan = async (id: number): Promise<void> => {
|
||||
const response = await authFetch(`/supply/fabric-plans/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Não foi possível remover o plano de malha.');
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchSupplyPurchaseNeeds = async (): Promise<SupplyPurchaseNeed[]> => {
|
||||
try {
|
||||
const response = await authFetch('/supply/purchase-needs');
|
||||
if (!response.ok) return [];
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch supply purchase needs failed', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const adjustSupplyLotInventory = async (lotId: number, payload: SupplyInventoryAdjustmentPayload): Promise<SupplyLot> => {
|
||||
const response = await authFetch(`/supply/lots/${lotId}/inventory-adjustment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível ajustar o inventário.');
|
||||
}
|
||||
|
||||
return data as SupplyLot;
|
||||
};
|
||||
|
||||
export const consumeSupplyLotForProduction = async (lotId: number, payload: SupplyProductionExitPayload): Promise<SupplyLot> => {
|
||||
const response = await authFetch(`/supply/lots/${lotId}/production-exit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível registrar a saída para produção.');
|
||||
}
|
||||
|
||||
return data as SupplyLot;
|
||||
};
|
||||
|
||||
export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise<DashboardAnalytics | null> => {
|
||||
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
try {
|
||||
const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {});
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch dashboard analytics failed', error);
|
||||
return null;
|
||||
}
|
||||
}, {
|
||||
...options,
|
||||
shouldCache: (data) => {
|
||||
const metrics = data as DashboardAnalytics | null;
|
||||
return Boolean(
|
||||
metrics &&
|
||||
Array.isArray(metrics.revenueBySeller) &&
|
||||
Array.isArray(metrics.ordersBySeller) &&
|
||||
Array.isArray(metrics.sellerRevenueByDate) &&
|
||||
Array.isArray(metrics.sellerRevenueByHour)
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchProductAnalytics = async (dateRange: DateRange): Promise<ProductAnalyticsItem[]> => {
|
||||
const path = `/analytics/products?${buildDateRangeParams(dateRange).toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
try {
|
||||
const response = await authFetch(path);
|
||||
if (!response.ok) return [];
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch product analytics failed', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchProductDetailsAnalytics = async (productId: string, dateRange: DateRange): Promise<ProductDetailsAnalytics | null> => {
|
||||
const path = `/analytics/products/${encodeURIComponent(productId)}/details?${buildDateRangeParams(dateRange).toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
try {
|
||||
const response = await authFetch(path);
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch product details analytics failed', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchProductComposition = async (productId: string): Promise<ProductComposition | null> => {
|
||||
try {
|
||||
const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/composition`);
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json() as { composition?: ProductComposition | null };
|
||||
return data.composition || null;
|
||||
} catch (error) {
|
||||
console.error('Fetch product composition failed', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalytics | null> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
start: formatDateParam(dateRange.start),
|
||||
end: formatDateParam(dateRange.end)
|
||||
});
|
||||
const response = await authFetch(`/analytics/rfm?${params.toString()}`);
|
||||
if (!response.ok) return await buildFallbackRfmAnalytics(dateRange);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch RFM analytics failed', error);
|
||||
return await buildFallbackRfmAnalytics(dateRange);
|
||||
export const exportProductCompositions = async (): Promise<number> => {
|
||||
const response = await authFetch('/analytics/product-compositions');
|
||||
const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível exportar as composições.');
|
||||
}
|
||||
|
||||
const compositions = data?.compositions || [];
|
||||
const blob = new Blob([JSON.stringify({
|
||||
exportedAt: new Date().toISOString(),
|
||||
compositions
|
||||
}, null, 2)], { type: 'application/json;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `composicoes_produtos_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return compositions.length;
|
||||
};
|
||||
|
||||
export const fetchClientAnalytics = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => {
|
||||
try {
|
||||
return await fetchClientAnalyticsForRange(dateRange);
|
||||
} catch (error) {
|
||||
console.error('Fetch client analytics failed', error);
|
||||
return [];
|
||||
}
|
||||
const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
|
||||
if (!filters) return;
|
||||
if (filters.marketplace) params.set('marketplace', filters.marketplace);
|
||||
if (filters.canal_venda) params.set('canal_venda', filters.canal_venda);
|
||||
if (filters.seller) params.set('seller', filters.seller);
|
||||
};
|
||||
|
||||
const hasClientFilterOptions = (data: unknown) => {
|
||||
const options = data as ClientFilterOptions | null;
|
||||
return Boolean(
|
||||
options?.marketplaces?.length ||
|
||||
options?.salesChannels?.length ||
|
||||
options?.sellers?.length
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchClientFilterOptions = async (dateRange: DateRange, options?: CacheOptions): Promise<ClientFilterOptions> => {
|
||||
const path = `/analytics/clients/filters?${buildDateRangeParams(dateRange).toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
try {
|
||||
const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {});
|
||||
if (!response.ok) return { marketplaces: [], salesChannels: [], sellers: [] };
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch client filter options failed', error);
|
||||
return { marketplaces: [], salesChannels: [], sellers: [] };
|
||||
}
|
||||
}, {
|
||||
...options,
|
||||
shouldCache: hasClientFilterOptions
|
||||
});
|
||||
};
|
||||
|
||||
const buildRfmAnalyticsPath = (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>) => {
|
||||
const params = buildDateRangeParams(dateRange);
|
||||
appendClientMetadataFilterParams(params, filters);
|
||||
return `/analytics/rfm?${params.toString()}`;
|
||||
};
|
||||
|
||||
export const getCachedRfmAnalytics = (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>): RfmAnalytics | undefined => {
|
||||
return getCachedAnalyticsValue<RfmAnalytics>(buildRfmAnalyticsPath(dateRange, filters));
|
||||
};
|
||||
|
||||
export const fetchRfmAnalytics = async (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>, options?: CacheOptions): Promise<RfmAnalytics | null> => {
|
||||
const path = buildRfmAnalyticsPath(dateRange, filters);
|
||||
return getCachedAnalytics(path, async () => {
|
||||
try {
|
||||
const response = await authFetch(path);
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch RFM analytics failed', error);
|
||||
return null;
|
||||
}
|
||||
}, options);
|
||||
};
|
||||
|
||||
const buildClientAnalyticsPath = (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>) => {
|
||||
const params = buildDateRangeParams(dateRange);
|
||||
appendClientMetadataFilterParams(params, filters);
|
||||
return `/analytics/clients?${params.toString()}`;
|
||||
};
|
||||
|
||||
export const getCachedClientAnalytics = (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>): ClientAnalyticsItem[] | undefined => {
|
||||
return getCachedAnalyticsValue<ClientAnalyticsItem[]>(buildClientAnalyticsPath(dateRange, filters));
|
||||
};
|
||||
|
||||
export const getCachedClientFilterOptions = (dateRange: DateRange): ClientFilterOptions | undefined => {
|
||||
return getCachedAnalyticsValue<ClientFilterOptions>(`/analytics/clients/filters?${buildDateRangeParams(dateRange).toString()}`);
|
||||
};
|
||||
|
||||
export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>, options?: CacheOptions): Promise<ClientAnalyticsItem[]> => {
|
||||
const path = buildClientAnalyticsPath(dateRange, filters);
|
||||
return getCachedAnalytics(path, async () => {
|
||||
try {
|
||||
const response = await authFetch(path);
|
||||
if (!response.ok) return [];
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch client analytics failed', error);
|
||||
return [];
|
||||
}
|
||||
}, options);
|
||||
};
|
||||
|
||||
const emptyClientPurchasePattern = (): ClientPurchasePatternAnalytics => ({
|
||||
weekdayRangeLabel: 'Todo período',
|
||||
hourRangeLabel: 'Últimos 60 dias',
|
||||
purchaseWeekdays: ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab'].map(label => ({ label, value: 0 })),
|
||||
purchaseHours: Array.from({ length: 24 }, (_, hour) => ({
|
||||
label: `${String(hour).padStart(2, '0')}h`,
|
||||
value: 0
|
||||
}))
|
||||
});
|
||||
|
||||
export const fetchClientPurchasePatternAnalytics = async (options?: CacheOptions): Promise<ClientPurchasePatternAnalytics> => {
|
||||
const path = '/analytics/clients/purchase-pattern';
|
||||
return getCachedAnalytics(path, async () => {
|
||||
try {
|
||||
const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {});
|
||||
if (!response.ok) return emptyClientPurchasePattern();
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch client purchase pattern analytics failed', error);
|
||||
return emptyClientPurchasePattern();
|
||||
}
|
||||
}, options);
|
||||
};
|
||||
|
||||
export const fetchClientDetailsAnalytics = async (clientToken: string, dateRange: DateRange): Promise<ClientDetailsAnalytics | null> => {
|
||||
const path = `/analytics/clients/${encodeURIComponent(clientToken)}/details?${buildDateRangeParams(dateRange).toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
try {
|
||||
const response = await authFetch(path);
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch client details analytics failed', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const clearAnalyticsCache = () => {
|
||||
analyticsCache.clear();
|
||||
};
|
||||
|
||||
export const fetchCampaigns = async (): Promise<CampaignQueueSummary | null> => {
|
||||
@@ -374,23 +806,6 @@ export const deleteUser = async (id: number): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
// YYYY-MM-DD
|
||||
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||
} else {
|
||||
// DD-MM-YYYY
|
||||
return new Date(Number(parts[2]), Number(parts[1]) - 1, Number(parts[0]));
|
||||
}
|
||||
}
|
||||
const fallback = new Date(dateStr);
|
||||
return isNaN(fallback.getTime()) ? new Date(0) : fallback;
|
||||
};
|
||||
|
||||
export const exportToCSV = (data: Record<string, unknown>[], filename: string) => {
|
||||
if (!data || !data.length) return;
|
||||
|
||||
|
||||
41
src/displayFormatters.ts
Normal file
41
src/displayFormatters.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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(' ');
|
||||
};
|
||||
245
src/index.css
245
src/index.css
@@ -1,22 +1,66 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-brand-primary: #9ECAE1;
|
||||
--color-dark-bg: #0a0a0a;
|
||||
--color-brand-primary: #b8c7d1;
|
||||
--color-brand-contrast: #070707;
|
||||
--color-dark-bg: #070707;
|
||||
--color-dark-card: #141414;
|
||||
--color-dark-header: #141414;
|
||||
--color-dark-sidebar: #141414;
|
||||
--color-dark-border: #222222;
|
||||
--color-dark-input: #1a1a1a;
|
||||
--color-dark-text: #ededed;
|
||||
--color-dark-muted: #888888;
|
||||
--color-dark-header: #101010;
|
||||
--color-dark-sidebar: #111111;
|
||||
--color-dark-border: #282828;
|
||||
--color-dark-input: #1c1c1c;
|
||||
--color-dark-text: #f0f0ee;
|
||||
--color-dark-muted: #929292;
|
||||
--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;
|
||||
@apply bg-dark-bg text-dark-text;
|
||||
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;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@@ -29,3 +73,186 @@
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
AtSign,
|
||||
Check,
|
||||
Copy,
|
||||
@@ -8,10 +9,13 @@ import {
|
||||
Loader2,
|
||||
Mail,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Shield,
|
||||
Trash2,
|
||||
User as UserIcon,
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
UserX,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { createUser, deleteUser as deleteManagedUser, fetchUsers, updateUser } from '../dataService';
|
||||
@@ -31,25 +35,30 @@ const statusOptions: Array<{ key: StatusFilter; label: string }> = [
|
||||
{ key: 'inactive', label: 'Inativos' }
|
||||
];
|
||||
|
||||
const formatDateTime = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (!value || Number.isNaN(date.getTime())) return '-';
|
||||
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
const roleLabels: Record<string, string> = {
|
||||
admin: 'Admin',
|
||||
supervisor: 'Supervisor',
|
||||
producao: 'Produção',
|
||||
operacao: 'Operação',
|
||||
vendedor: 'Vendedor',
|
||||
financeiro: 'Financeiro',
|
||||
user: 'Usuário'
|
||||
};
|
||||
|
||||
const inputWrapClassName = 'flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary';
|
||||
const inputClassName = 'min-w-0 flex-1 bg-transparent text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted';
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return '?';
|
||||
return parts.slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
||||
};
|
||||
|
||||
const getUserRoleLabel = (user: ManagedUser) => {
|
||||
const role = String((user as ManagedUser & { role?: string }).role || 'user').toLowerCase();
|
||||
return roleLabels[role] || role;
|
||||
};
|
||||
|
||||
const AdminUsers = () => {
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -87,6 +96,8 @@ const AdminUsers = () => {
|
||||
});
|
||||
}, [searchTerm, statusFilter, users]);
|
||||
|
||||
const hasFilters = searchTerm.trim() !== '' || statusFilter !== 'all';
|
||||
|
||||
const loadUsers = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -119,6 +130,11 @@ const AdminUsers = () => {
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm('');
|
||||
setStatusFilter('all');
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
resetForm();
|
||||
setIsModalOpen(true);
|
||||
@@ -251,129 +267,200 @@ const AdminUsers = () => {
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-amber-200'
|
||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300';
|
||||
|
||||
const statusCounts: Record<StatusFilter, number> = {
|
||||
all: users.length,
|
||||
active: activeCount,
|
||||
inactive: inactiveCount
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{ label: 'Total', value: users.length, icon: UserIcon, tone: 'text-dark-text', detail: 'acessos cadastrados' },
|
||||
{ label: 'Ativos', value: activeCount, icon: UserCheck, tone: 'text-emerald-300', detail: 'podem entrar agora' },
|
||||
{ label: 'Inativos', value: inactiveCount, icon: UserX, tone: 'text-red-300', detail: 'bloqueados no login' },
|
||||
{ label: 'Exibindo', value: filteredUsers.length, icon: Search, tone: 'text-brand-primary', detail: hasFilters ? 'após filtros' : 'sem filtros' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-brand-primary">
|
||||
<Shield className="h-4 w-4" />
|
||||
Super admin
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-dark-text">Usuários</h1>
|
||||
<p className="mt-1 text-sm text-dark-muted">Controle quem acessa o painel.</p>
|
||||
<header className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<h1 className="text-3xl font-bold text-dark-text">Usuários</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-dark-muted">
|
||||
Gerencie acessos individuais, senhas iniciais e bloqueios de entrada no painel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadUsers()}
|
||||
disabled={isLoading}
|
||||
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Atualizar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateModal}
|
||||
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-zinc-950 transition-colors hover:bg-brand-primary/90"
|
||||
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Novo usuário
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Novo acesso
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-dark-border bg-dark-card">
|
||||
<div className="flex flex-col gap-4 border-b border-dark-border p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="grid grid-cols-3 divide-x divide-dark-border overflow-hidden rounded-lg border border-dark-border bg-dark-input lg:w-[360px]">
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs font-semibold text-dark-muted">Total</p>
|
||||
<p className="mt-1 text-xl font-bold text-dark-text">{users.length}</p>
|
||||
<section className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
|
||||
<p className={`mt-2 text-2xl font-bold ${stat.tone}`}>{stat.value}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{stat.detail}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs font-semibold text-dark-muted">Ativos</p>
|
||||
<p className="mt-1 text-xl font-bold text-emerald-300">{activeCount}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs font-semibold text-dark-muted">Inativos</p>
|
||||
<p className="mt-1 text-xl font-bold text-red-300">{inactiveCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="flex h-10 min-w-0 items-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 md:w-80">
|
||||
<Search className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||
<input
|
||||
type="search"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-dark-text outline-none placeholder:text-dark-muted"
|
||||
placeholder="Buscar nome ou e-mail"
|
||||
/>
|
||||
<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="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}
|
||||
</button>
|
||||
))}
|
||||
<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="flex h-80 items-center justify-center text-brand-primary">
|
||||
<Loader2 className="h-7 w-7 animate-spin" />
|
||||
<div className="divide-y divide-dark-border" aria-label="Carregando usuários">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={`user-skeleton-${index}`} className="grid grid-cols-[1.4fr_1fr_120px_140px_88px] gap-5 px-5 py-4">
|
||||
{Array.from({ length: 5 }).map((__, column) => (
|
||||
<div key={`user-skeleton-${index}-${column}`} className="skeleton h-4" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="flex h-80 flex-col items-center justify-center px-6 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-dark-input text-dark-muted">
|
||||
<UserIcon className="h-6 w-6" />
|
||||
<div className="flex min-h-[360px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-dark-border bg-dark-input text-brand-primary">
|
||||
<UserPlus className="h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-base font-bold text-dark-text">Nenhum usuário cadastrado</p>
|
||||
<p className="mt-1 max-w-sm text-sm text-dark-muted">Crie o primeiro acesso para liberar o painel a outra pessoa.</p>
|
||||
<p className="text-lg font-bold text-dark-text">Nenhum usuário cadastrado</p>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-dark-muted">Crie o primeiro acesso individual para tirar o uso compartilhado do login administrativo.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateModal}
|
||||
className="mt-5 inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Criar primeiro acesso
|
||||
</button>
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="flex h-80 flex-col items-center justify-center px-6 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-dark-input text-dark-muted">
|
||||
<Search className="h-6 w-6" />
|
||||
<div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-dark-border bg-dark-input text-dark-muted">
|
||||
<Search className="h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-base font-bold text-dark-text">Nenhum resultado</p>
|
||||
<p className="mt-1 text-sm text-dark-muted">Ajuste a busca ou limpe os filtros.</p>
|
||||
<p className="text-lg font-bold text-dark-text">Nenhum resultado</p>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-dark-muted">Não encontramos usuários com os filtros atuais.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="mt-5 inline-flex h-10 cursor-pointer items-center justify-center rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
|
||||
>
|
||||
Limpar filtros
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[760px] text-left">
|
||||
<thead className="border-b border-dark-border text-xs uppercase tracking-widest text-dark-muted">
|
||||
<table className="w-full min-w-[900px] table-fixed text-left">
|
||||
<thead className="border-b border-dark-border bg-dark-input/35 text-xs uppercase tracking-widest text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-bold">Usuário</th>
|
||||
<th className="px-5 py-3 font-bold">E-mail</th>
|
||||
<th className="px-5 py-3 font-bold">Status</th>
|
||||
<th className="px-5 py-3 text-right font-bold">Criado em</th>
|
||||
<th className="px-5 py-3 text-right font-bold">Ações</th>
|
||||
<th className="w-[34%] px-5 py-3 font-bold">Usuário</th>
|
||||
<th className="w-[24%] px-5 py-3 font-bold">E-mail</th>
|
||||
<th className="w-[16%] px-5 py-3 font-bold">Perfil</th>
|
||||
<th className="w-[14%] px-5 py-3 font-bold">Status</th>
|
||||
<th className="w-[12%] px-5 py-3 text-right font-bold">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.id} className="text-sm transition-colors hover:bg-dark-input/45">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-primary/10 text-sm font-bold text-brand-primary">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-primary/10 text-sm font-bold text-brand-primary">
|
||||
{getInitials(user.name)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-dark-text">{user.name}</p>
|
||||
<p className="truncate font-bold text-dark-text">{user.name}</p>
|
||||
<p className="mt-0.5 text-xs font-semibold text-dark-muted">ID {user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex min-w-0 items-center gap-2 text-dark-muted">
|
||||
<Mail className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{user.email}</span>
|
||||
<span className="truncate font-semibold">{user.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="inline-flex rounded-full border border-brand-primary/20 bg-brand-primary/10 px-2.5 py-1 text-xs font-bold text-brand-primary">
|
||||
{getUserRoleLabel(user)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold ${
|
||||
user.isActive ? 'bg-emerald-500/10 text-emerald-300' : 'bg-red-500/10 text-red-300'
|
||||
@@ -382,13 +469,12 @@ const AdminUsers = () => {
|
||||
{user.isActive ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right text-dark-muted">{formatDateTime(user.createdAt)}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(user)}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary/50 hover:text-brand-primary"
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary/50 hover:text-brand-primary"
|
||||
aria-label={`Editar ${user.name}`}
|
||||
title="Editar"
|
||||
>
|
||||
@@ -400,7 +486,7 @@ const AdminUsers = () => {
|
||||
setNotice(null);
|
||||
setUserToDelete(user);
|
||||
}}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300"
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300"
|
||||
aria-label={`Excluir ${user.name}`}
|
||||
title="Excluir"
|
||||
>
|
||||
@@ -417,14 +503,19 @@ const AdminUsers = () => {
|
||||
</section>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6">
|
||||
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-dark-border bg-dark-card shadow-2xl">
|
||||
<div className="flex items-start justify-between border-b border-dark-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-dark-text">{editingUser ? 'Editar usuário' : 'Novo usuário'}</h2>
|
||||
<p className="mt-1 text-sm text-dark-muted">
|
||||
{editingUser ? 'Atualize dados, status e senha de acesso.' : 'Crie um acesso individual para o painel.'}
|
||||
</p>
|
||||
<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"
|
||||
@@ -437,130 +528,167 @@ const AdminUsers = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser} className="space-y-4 p-5">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">Nome</span>
|
||||
<div className="flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary">
|
||||
<UserIcon className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted"
|
||||
placeholder="Nome completo"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<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="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">
|
||||
<AtSign className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted"
|
||||
placeholder="usuario@empresa.com"
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">{editingUser ? 'Senha' : 'Senha inicial'}</span>
|
||||
<div className="grid grid-cols-2 rounded-lg border border-dark-border bg-dark-input p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasswordMode('auto')}
|
||||
className={`h-9 cursor-pointer rounded-md text-sm font-semibold transition-colors ${passwordMode === 'auto' ? 'bg-dark-card text-dark-text' : 'text-dark-muted hover:text-dark-text'}`}
|
||||
>
|
||||
{editingUser ? 'Manter' : 'Gerar'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasswordMode('manual')}
|
||||
className={`h-9 cursor-pointer rounded-md text-sm font-semibold transition-colors ${passwordMode === 'manual' ? 'bg-dark-card text-dark-text' : 'text-dark-muted hover:text-dark-text'}`}
|
||||
>
|
||||
{editingUser ? 'Alterar' : 'Definir'}
|
||||
</button>
|
||||
</div>
|
||||
<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 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">
|
||||
<KeyRound className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||
{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="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted"
|
||||
placeholder="Mínimo de 6 caracteres"
|
||||
minLength={6}
|
||||
required
|
||||
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>
|
||||
|
||||
{editingUser && (
|
||||
<label className="flex cursor-pointer items-center justify-between rounded-lg border border-dark-border bg-dark-input px-3 py-3">
|
||||
<div>
|
||||
<span className="block text-sm font-semibold text-dark-text">Usuário ativo</span>
|
||||
<span className="mt-0.5 block text-xs text-dark-muted">Usuários inativos não conseguem entrar.</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(event) => setIsActive(event.target.checked)}
|
||||
className="h-5 w-5 cursor-pointer accent-brand-primary"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div className={`rounded-lg border px-3 py-3 text-sm ${noticeClass}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
{notice.tone === 'error' ? <X className="mt-0.5 h-4 w-4 shrink-0" /> : <Check className="mt-0.5 h-4 w-4 shrink-0" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p>{notice.text}</p>
|
||||
{notice.temporaryPassword && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-md bg-black/25 px-3 py-2 text-amber-100">
|
||||
{notice.temporaryPassword}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyTemporaryPassword}
|
||||
className="inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-amber-400/30 px-3 text-xs font-bold text-amber-100 transition-colors hover:bg-amber-400/10"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{copyLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col-reverse gap-2 border-t border-dark-border bg-dark-input/35 px-5 py-4 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
disabled={isSubmitting}
|
||||
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-semibold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<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-zinc-950 transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||
{editingUser ? 'Salvar alterações' : 'Criar usuário'}
|
||||
{editingUser ? 'Salvar alterações' : 'Criar acesso'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -570,19 +698,24 @@ const AdminUsers = () => {
|
||||
|
||||
{userToDelete && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-lg border border-dark-border bg-dark-card shadow-2xl">
|
||||
<div className="border-b border-dark-border px-5 py-4">
|
||||
<h2 className="text-lg font-bold text-dark-text">Excluir usuário</h2>
|
||||
<p className="mt-1 text-sm text-dark-muted">Esta ação remove o acesso imediatamente.</p>
|
||||
<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-lg border border-dark-border bg-dark-input p-4">
|
||||
<p className="font-semibold text-dark-text">{userToDelete.name}</p>
|
||||
<p className="mt-1 text-sm text-dark-muted">{userToDelete.email}</p>
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input p-4">
|
||||
<p className="font-bold text-dark-text">{userToDelete.name}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">{userToDelete.email}</p>
|
||||
</div>
|
||||
|
||||
{notice?.tone === 'error' && (
|
||||
<div className={`rounded-lg border px-3 py-3 text-sm ${noticeClass}`}>
|
||||
<div className={`rounded-xl border px-3 py-3 text-sm font-semibold ${noticeClass}`}>
|
||||
{notice.text}
|
||||
</div>
|
||||
)}
|
||||
@@ -596,7 +729,7 @@ const AdminUsers = () => {
|
||||
setNotice(null);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-semibold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Clock, Megaphone, RefreshCw, RotateCcw, Send, XCircle } from 'lucide-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';
|
||||
|
||||
@@ -11,12 +12,21 @@ const statusLabels: Record<CampaignStatus, string> = {
|
||||
skipped: 'Ignorada'
|
||||
};
|
||||
|
||||
const statusStyles: Record<CampaignStatus, string> = {
|
||||
pending: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
||||
processing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
sent: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
failed: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
skipped: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'
|
||||
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> = {
|
||||
@@ -34,12 +44,75 @@ const formatDate = (value?: string | null) => {
|
||||
|
||||
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);
|
||||
@@ -72,6 +145,62 @@ const Campaigns = () => {
|
||||
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();
|
||||
@@ -106,7 +235,7 @@ const Campaigns = () => {
|
||||
<button
|
||||
onClick={handleProcessNow}
|
||||
disabled={isProcessing || !preview?.readyProducts.length}
|
||||
className="inline-flex items-center gap-2 bg-brand-primary text-black 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"
|
||||
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
|
||||
@@ -114,21 +243,35 @@ const Campaigns = () => {
|
||||
</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-4 text-sm text-dark-muted">
|
||||
Resultado: {processResult.claimed} itens processados, {processResult.sentGroups} grupos enviados, {processResult.failedGroups} falhas, {processResult.pendingBelowThresholdGroups} abaixo do limite.
|
||||
<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">
|
||||
{Object.entries(groupedCounts).map(([status, count]) => {
|
||||
const typedStatus = status as CampaignStatus;
|
||||
const Icon = statusIcons[typedStatus];
|
||||
{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">{statusLabels[typedStatus]}</span>
|
||||
<Icon className="w-4 h-4 text-brand-primary" />
|
||||
<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>
|
||||
@@ -157,36 +300,85 @@ const Campaigns = () => {
|
||||
<p className="text-xl font-bold text-dark-text">{summary?.threshold ?? preview?.threshold ?? 100}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(preview?.readyProducts || []).map(product => (
|
||||
<div key={product.baseProduct} className="flex justify-between gap-4 border border-emerald-500/20 bg-emerald-500/5 rounded-xl p-3">
|
||||
<span className="font-semibold text-dark-text">{product.baseProduct}</span>
|
||||
<span className="text-emerald-400 font-bold">{formatDelta(product.total_delta)}</span>
|
||||
</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>
|
||||
))}
|
||||
{(preview?.belowThresholdProducts || []).map(product => (
|
||||
<div key={product.baseProduct} className="flex justify-between gap-4 border border-dark-border bg-dark-input rounded-xl p-3">
|
||||
<span className="font-semibold text-dark-muted">{product.baseProduct}</span>
|
||||
<span className="text-yellow-400 font-bold">{formatDelta(product.total_delta)}</span>
|
||||
</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">
|
||||
<h2 className="text-lg font-bold text-dark-text mb-4">Top clientes da campanha</h2>
|
||||
<div className="space-y-2">
|
||||
{(preview?.customersPreview || []).map(customer => (
|
||||
<div key={`${customer.fone}-${customer.nome}`} className="flex items-center justify-between gap-4 border border-dark-border rounded-xl p-3">
|
||||
<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="text-xs font-bold text-brand-primary shrink-0">{customer.total_comprado || 0} un.</span>
|
||||
<span className="ml-auto text-xs font-bold text-brand-primary shrink-0">{customer.total_comprado || 0} un.</span>
|
||||
</div>
|
||||
))}
|
||||
{!preview?.customersPreview.length && <p className="text-dark-muted text-sm">Nenhum cliente com telefone válido encontrado.</p>}
|
||||
{!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>
|
||||
@@ -215,7 +407,10 @@ const Campaigns = () => {
|
||||
{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 ${statusStyles[group.status]}`}>
|
||||
<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>
|
||||
@@ -246,6 +441,8 @@ const Campaigns = () => {
|
||||
<div className="p-8 text-center text-dark-muted">Nenhuma campanha registrada ainda.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronLeft, ChevronRight, ShoppingBag, ReceiptText } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
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 type { DateRange, OrderData } from '../types';
|
||||
import { buildClientDetailsMetrics } from '../analytics/clients';
|
||||
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;
|
||||
@@ -12,6 +30,22 @@ type CustomTooltipProps = {
|
||||
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 (
|
||||
@@ -26,31 +60,149 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
||||
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>
|
||||
);
|
||||
|
||||
const ClientDetails = () => {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const decodedName = name ? decodeURIComponent(name) : '';
|
||||
const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{
|
||||
const { clientToken } = useParams<{ clientToken: string }>();
|
||||
const decodedClientToken = clientToken ? decodeURIComponent(clientToken) : '';
|
||||
const { dateRange, setDateRange } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void,
|
||||
ordersData: OrderData[],
|
||||
isDataLoading: boolean
|
||||
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 {
|
||||
chartData,
|
||||
groupedOrders,
|
||||
allTimeOrderCount,
|
||||
clientPhone,
|
||||
hasClient,
|
||||
periodAverageTicket,
|
||||
periodItems,
|
||||
periodOrderCount,
|
||||
periodSpent
|
||||
} = useMemo(() => {
|
||||
return buildClientDetailsMetrics(ordersData, decodedName, dateRange);
|
||||
}, [dateRange, decodedName, ordersData]);
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadClientDetails = async () => {
|
||||
if (!decodedClientToken) {
|
||||
if (isMounted) {
|
||||
setDetails(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const nextDetails = await fetchClientDetailsAnalytics(decodedClientToken, dateRange);
|
||||
|
||||
if (isMounted) {
|
||||
setDetails(nextDetails);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadClientDetails();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [dateRange, decodedClientToken]);
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
@@ -62,23 +214,27 @@ const ClientDetails = () => {
|
||||
|
||||
const handleDateRangeChange = (range: DateRange) => {
|
||||
setCurrentPage(1);
|
||||
setExpandedOrderIds(new Set());
|
||||
setDateRange(range);
|
||||
};
|
||||
|
||||
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 toggleOrder = (orderId: string) => {
|
||||
setExpandedOrderIds(current => {
|
||||
const next = new Set(current);
|
||||
if (next.has(orderId)) {
|
||||
next.delete(orderId);
|
||||
} else {
|
||||
next.add(orderId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!hasClient && isDataLoading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando cliente...</p>
|
||||
</div>
|
||||
);
|
||||
if (isLoading && !details) {
|
||||
return <ClientDetailsSkeleton />;
|
||||
}
|
||||
|
||||
if (!hasClient) {
|
||||
if (!details?.hasClient) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Cliente não encontrado.</p>
|
||||
@@ -87,14 +243,40 @@ 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">
|
||||
<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>
|
||||
<BackButton fallbackTo="/clients" />
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -102,7 +284,7 @@ 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">{decodedName}</h1>
|
||||
<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
|
||||
@@ -127,6 +309,9 @@ const ClientDetails = () => {
|
||||
</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>
|
||||
@@ -167,7 +352,9 @@ const ClientDetails = () => {
|
||||
</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 Data</h3>
|
||||
<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.
|
||||
@@ -175,27 +362,144 @@ const ClientDetails = () => {
|
||||
) : (
|
||||
<div className="h-[320px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} />
|
||||
<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="#888888"
|
||||
stroke={CHART_AXIS_COLOR}
|
||||
fontSize={10}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
interval={isSingleDayRange ? 2 : 0}
|
||||
angle={isSingleDayRange ? 0 : -45}
|
||||
textAnchor={isSingleDayRange ? 'middle' : 'end'}
|
||||
height={isSingleDayRange ? 24 : 80}
|
||||
/>
|
||||
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(value) => formatCurrency(Number(value))} />
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#222222' }} />
|
||||
<Bar dataKey="value" fill="#9ECAE1" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
<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>
|
||||
{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>
|
||||
{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>
|
||||
{/* Orders List */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{paginatedOrders.length === 0 ? (
|
||||
@@ -203,20 +507,54 @@ const ClientDetails = () => {
|
||||
<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, 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>
|
||||
) : 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>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||
{group.items.map((order, index) => (
|
||||
</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 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">
|
||||
@@ -245,6 +583,15 @@ 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">
|
||||
@@ -252,52 +599,32 @@ 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>
|
||||
|
||||
<div className="px-6 py-4 border border-zinc-200 dark:border-dark-border rounded-2xl bg-white dark:bg-dark-card flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-dark-muted">
|
||||
<span>Mostrar</span>
|
||||
<select
|
||||
value={ordersPerPage}
|
||||
onChange={(event) => {
|
||||
setOrdersPerPage(Number(event.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<option value={5}>5</option>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
</select>
|
||||
<span>pedidos por página</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-zinc-500 dark:text-dark-muted">
|
||||
Mostrando {groupedOrders.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + ordersPerPage, groupedOrders.length)} de {groupedOrders.length} pedidos
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
|
||||
disabled={safeCurrentPage === 1}
|
||||
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"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(page => Math.min(totalPages, page + 1))}
|
||||
disabled={safeCurrentPage === totalPages || totalPages === 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"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,55 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react';
|
||||
import type { ClientAnalyticsItem, DateRange, RfmAnalytics, RfmClient } from '../types';
|
||||
import { exportToCSV, fetchClientAnalytics, fetchRfmAnalytics } from '../dataService';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import { Search, ChevronRight, Filter, X } from 'lucide-react';
|
||||
import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, DateRange, RfmAnalytics, RfmClient } from '../types';
|
||||
import { fetchClientAnalytics, fetchClientFilterOptions, fetchClientPurchasePatternAnalytics, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService';
|
||||
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
|
||||
import type { ClientSortOption, ClientSummary } from '../analytics/clients';
|
||||
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
|
||||
|
||||
const clientTypeStyles: Record<string, string> = {
|
||||
'Sem análise': 'border-zinc-500/30 bg-zinc-500/15 text-zinc-300',
|
||||
'Campeão': 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300',
|
||||
'Potencial Leal': 'border-sky-500/30 bg-sky-500/15 text-sky-300',
|
||||
'Novo Cliente': 'border-cyan-500/30 bg-cyan-500/15 text-cyan-300',
|
||||
'Cliente Leal': 'border-blue-500/30 bg-blue-500/15 text-blue-300',
|
||||
'Precisa de Atenção': 'border-amber-500/30 bg-amber-500/15 text-amber-300',
|
||||
'Quase Dormindo': 'border-orange-500/30 bg-orange-500/15 text-orange-300',
|
||||
'Em Risco': 'border-rose-500/30 bg-rose-500/15 text-rose-300',
|
||||
'Hibernando': 'border-fuchsia-500/30 bg-fuchsia-500/15 text-fuchsia-300',
|
||||
'Perdido': 'border-zinc-500/30 bg-zinc-500/15 text-zinc-300'
|
||||
type ClientTypeStyle = {
|
||||
bg: string;
|
||||
border: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ThemeMode = 'dark' | 'offwhite';
|
||||
|
||||
const darkClientTypeStyles: Record<string, ClientTypeStyle> = {
|
||||
'Sem análise': { bg: 'rgba(140, 146, 152, 0.08)', border: 'rgba(140, 146, 152, 0.24)', text: '#b3b7bb' },
|
||||
'Campeão': { bg: 'rgba(24, 214, 181, 0.10)', border: 'rgba(24, 214, 181, 0.34)', text: '#18D6B5' },
|
||||
'Potencial Leal': { bg: 'rgba(163, 230, 53, 0.10)', border: 'rgba(163, 230, 53, 0.34)', text: '#A3E635' },
|
||||
'Novo Cliente': { bg: 'rgba(37, 194, 255, 0.10)', border: 'rgba(37, 194, 255, 0.34)', text: '#25C2FF' },
|
||||
'Cliente Leal': { bg: 'rgba(110, 165, 255, 0.10)', border: 'rgba(110, 165, 255, 0.34)', text: '#6EA5FF' },
|
||||
'Precisa de Atenção': { bg: 'rgba(255, 194, 71, 0.11)', border: 'rgba(255, 194, 71, 0.34)', text: '#FFC247' },
|
||||
'Quase Dormindo': { bg: 'rgba(255, 138, 61, 0.11)', border: 'rgba(255, 138, 61, 0.34)', text: '#FF8A3D' },
|
||||
'Em Risco': { bg: 'rgba(255, 93, 125, 0.11)', border: 'rgba(255, 93, 125, 0.34)', text: '#FF5D7D' },
|
||||
'Hibernando': { bg: 'rgba(199, 125, 255, 0.11)', border: 'rgba(199, 125, 255, 0.34)', text: '#C77DFF' },
|
||||
'Perdido': { bg: 'rgba(140, 146, 152, 0.08)', border: 'rgba(140, 146, 152, 0.24)', text: '#b3b7bb' }
|
||||
};
|
||||
|
||||
const offwhiteClientTypeStyles: Record<string, ClientTypeStyle> = {
|
||||
'Sem análise': { bg: 'rgba(117, 124, 130, 0.08)', border: 'rgba(117, 124, 130, 0.26)', text: '#757C82' },
|
||||
'Campeão': { bg: 'rgba(19, 139, 117, 0.10)', border: 'rgba(19, 139, 117, 0.34)', text: '#138B75' },
|
||||
'Potencial Leal': { bg: 'rgba(95, 143, 18, 0.11)', border: 'rgba(95, 143, 18, 0.34)', text: '#5F8F12' },
|
||||
'Novo Cliente': { bg: 'rgba(11, 126, 168, 0.10)', border: 'rgba(11, 126, 168, 0.34)', text: '#0B7EA8' },
|
||||
'Cliente Leal': { bg: 'rgba(53, 109, 204, 0.10)', border: 'rgba(53, 109, 204, 0.34)', text: '#356DCC' },
|
||||
'Precisa de Atenção': { bg: 'rgba(163, 108, 0, 0.11)', border: 'rgba(163, 108, 0, 0.34)', text: '#A36C00' },
|
||||
'Quase Dormindo': { bg: 'rgba(183, 91, 18, 0.11)', border: 'rgba(183, 91, 18, 0.34)', text: '#B75B12' },
|
||||
'Em Risco': { bg: 'rgba(199, 54, 86, 0.10)', border: 'rgba(199, 54, 86, 0.34)', text: '#C73656' },
|
||||
'Hibernando': { bg: 'rgba(140, 82, 214, 0.10)', border: 'rgba(140, 82, 214, 0.34)', text: '#8C52D6' },
|
||||
'Perdido': { bg: 'rgba(117, 124, 130, 0.08)', border: 'rgba(117, 124, 130, 0.26)', text: '#757C82' }
|
||||
};
|
||||
|
||||
const clientTypePillStyle = (style: ClientTypeStyle) => ({
|
||||
backgroundColor: style.bg,
|
||||
borderColor: style.border,
|
||||
color: style.text
|
||||
});
|
||||
|
||||
const clientTypes = [
|
||||
'Sem análise',
|
||||
'Campeão',
|
||||
@@ -64,16 +95,153 @@ const sortClients = (clients: ClientSummary[], sortBy: ClientSortOption) => {
|
||||
});
|
||||
};
|
||||
|
||||
const emptyClientFilters: ClientMetadataFilters = {
|
||||
marketplace: '',
|
||||
canal_venda: '',
|
||||
seller: ''
|
||||
};
|
||||
|
||||
const emptyClientFilterOptions: ClientFilterOptions = {
|
||||
marketplaces: [],
|
||||
salesChannels: [],
|
||||
sellers: []
|
||||
};
|
||||
|
||||
const dateFilterPresets = [
|
||||
{ value: 'today', label: 'Hoje', getRange: () => rangeForDay(new Date()) },
|
||||
{ value: 'yesterday', label: 'Ontem', getRange: () => rangeForPreviousDay() },
|
||||
{ value: '7d', label: 'Últimos 7 dias', getRange: () => rangeForLastDays(7) },
|
||||
{ value: '30d', label: 'Últimos 30 dias', getRange: () => rangeForLastDays(30) },
|
||||
{ value: 'month', label: 'Este mês', getRange: () => {
|
||||
const end = endOfLocalDay(new Date());
|
||||
return { start: startOfLocalDay(new Date(end.getFullYear(), end.getMonth(), 1)), end };
|
||||
} },
|
||||
{ value: 'previous-month', label: 'Mês passado', getRange: () => {
|
||||
const today = new Date();
|
||||
return {
|
||||
start: startOfLocalDay(new Date(today.getFullYear(), today.getMonth() - 1, 1)),
|
||||
end: endOfLocalDay(new Date(today.getFullYear(), today.getMonth(), 0))
|
||||
};
|
||||
} },
|
||||
{ value: '90d', label: 'Últimos 90 dias', getRange: () => rangeForLastDays(90) },
|
||||
{ value: 'year', label: 'Este ano', getRange: () => {
|
||||
const end = endOfLocalDay(new Date());
|
||||
return { start: startOfLocalDay(new Date(end.getFullYear(), 0, 1)), end };
|
||||
} },
|
||||
{ value: 'all', label: 'Todo o período', getRange: () => ({
|
||||
start: startOfLocalDay(new Date(2000, 0, 1)),
|
||||
end: endOfLocalDay(new Date())
|
||||
}) }
|
||||
];
|
||||
|
||||
const filterSelectClassName = "w-full h-9 bg-dark-input border border-dark-border text-dark-text text-sm rounded-lg px-3 focus:outline-none focus:border-brand-primary transition-colors cursor-pointer";
|
||||
const CHART_GRID_COLOR = 'var(--chart-grid)';
|
||||
const CHART_AXIS_COLOR = 'var(--chart-axis)';
|
||||
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
|
||||
const WEEKDAY_BAR_COLOR = '#25C2FF';
|
||||
const HOUR_BAR_COLOR = '#52DFA0';
|
||||
|
||||
type PatternTooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number }>;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const PatternTooltip = ({ active, payload, label }: PatternTooltipProps) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const ClientPurchasePatternSkeleton = () => (
|
||||
<section className="space-y-4" aria-label="Carregando padrão de compra">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<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={`clients-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={`clients-pattern-bar-skeleton-${item}-${bar}`} className="skeleton flex-1" style={{ height: `${20 + ((bar * 17) % 65)}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const ClientsTableSkeleton = () => (
|
||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm" aria-label="Carregando clientes">
|
||||
<div className="border-b border-zinc-100 p-4 dark:border-dark-border">
|
||||
<div className="grid grid-cols-[72px_1.4fr_150px_110px_140px_140px_170px_110px] gap-5">
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map(item => (
|
||||
<div key={`clients-head-skeleton-${item}`} className="skeleton h-3" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
|
||||
<div key={`clients-row-skeleton-${row}`} className="grid grid-cols-[72px_1.4fr_150px_110px_140px_140px_170px_110px] gap-5 px-6 py-4">
|
||||
<div className="skeleton h-7 w-7 rounded-full" />
|
||||
<div className="skeleton h-4 w-4/5" />
|
||||
<div className="skeleton h-7 rounded-full" />
|
||||
<div className="flex gap-1">
|
||||
<div className="skeleton h-6 w-6 rounded-md" />
|
||||
<div className="skeleton h-6 w-6 rounded-md" />
|
||||
<div className="skeleton h-6 w-6 rounded-md" />
|
||||
</div>
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-zinc-100 p-4 dark:border-dark-border">
|
||||
<div className="skeleton h-4 w-48" />
|
||||
<div className="skeleton h-8 w-56" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Clients = () => {
|
||||
const { dateRange, setDateRange } = useOutletContext<{
|
||||
const { dateRange, setDateRange, themeMode } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void
|
||||
setDateRange: (range: DateRange) => void,
|
||||
themeMode?: ThemeMode
|
||||
}>();
|
||||
const clientTypeStyles = themeMode === 'offwhite' ? offwhiteClientTypeStyles : darkClientTypeStyles;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<ClientSortOption>('recent');
|
||||
const [clientTypeFilter, setClientTypeFilter] = useState('all');
|
||||
const [rfmAnalytics, setRfmAnalytics] = useState<RfmAnalytics | null>(null);
|
||||
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>([]);
|
||||
const [metadataFilters, setMetadataFilters] = useState<ClientMetadataFilters>(emptyClientFilters);
|
||||
const initialFilterOptions = getCachedClientFilterOptions(dateRange);
|
||||
const initialClientAnalytics = getCachedClientAnalytics(dateRange, emptyClientFilters);
|
||||
const initialRfmAnalytics = getCachedRfmAnalytics(dateRange, emptyClientFilters);
|
||||
const [filterOptions, setFilterOptions] = useState<ClientFilterOptions>(initialFilterOptions || emptyClientFilterOptions);
|
||||
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [rfmAnalytics, setRfmAnalytics] = useState<RfmAnalytics | null>(initialRfmAnalytics || null);
|
||||
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>(initialClientAnalytics || []);
|
||||
const [isLoading, setIsLoading] = useState(!initialClientAnalytics);
|
||||
const [isRfmLoading, setIsRfmLoading] = useState(!initialRfmAnalytics && Boolean(initialClientAnalytics));
|
||||
const [purchasePattern, setPurchasePattern] = useState<ClientPurchasePatternAnalytics | null>(null);
|
||||
const [isPatternLoading, setIsPatternLoading] = useState(true);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -83,14 +251,35 @@ const Clients = () => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadClients = async () => {
|
||||
const clientsData = await fetchClientAnalytics(dateRange);
|
||||
const cachedOptions = getCachedClientFilterOptions(dateRange);
|
||||
const cachedClients = getCachedClientAnalytics(dateRange, metadataFilters);
|
||||
const cachedRfm = getCachedRfmAnalytics(dateRange, metadataFilters);
|
||||
const hasCachedClients = Boolean(cachedClients);
|
||||
|
||||
if (isMounted) {
|
||||
setClientAnalytics(clientsData);
|
||||
if (cachedOptions) setFilterOptions(cachedOptions);
|
||||
if (cachedClients) setClientAnalytics(cachedClients);
|
||||
setRfmAnalytics(cachedRfm || null);
|
||||
setIsLoading(!hasCachedClients);
|
||||
setIsRfmLoading(!cachedRfm && hasCachedClients);
|
||||
}
|
||||
|
||||
const rfmData = await fetchRfmAnalytics(dateRange);
|
||||
const filterOptionsPromise = fetchClientFilterOptions(dateRange, { force: true });
|
||||
const clientsPromise = fetchClientAnalytics(dateRange, metadataFilters);
|
||||
const rfmPromise = fetchRfmAnalytics(dateRange, metadataFilters);
|
||||
const [options, clientsData] = await Promise.all([filterOptionsPromise, clientsPromise]);
|
||||
|
||||
if (isMounted) {
|
||||
setFilterOptions(options);
|
||||
setClientAnalytics(clientsData);
|
||||
setIsLoading(false);
|
||||
setIsRfmLoading(!cachedRfm);
|
||||
}
|
||||
|
||||
const rfmData = await rfmPromise;
|
||||
if (isMounted) {
|
||||
setRfmAnalytics(rfmData);
|
||||
setIsRfmLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,21 +288,79 @@ const Clients = () => {
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [dateRange]);
|
||||
}, [dateRange, metadataFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadPurchasePattern = async () => {
|
||||
setIsPatternLoading(true);
|
||||
const nextPattern = await fetchClientPurchasePatternAnalytics();
|
||||
|
||||
if (isMounted) {
|
||||
setPurchasePattern(nextPattern);
|
||||
setIsPatternLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadPurchasePattern();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFilterMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!filterMenuRef.current?.contains(event.target as Node)) {
|
||||
setIsFilterMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsFilterMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isFilterMenuOpen]);
|
||||
|
||||
const allClientsData = useMemo(() => {
|
||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||
const rfmByCustomerKey = new Map((rfmAnalytics?.clients || []).map(client => [client.customerKey, client]));
|
||||
const clients = clientAnalytics.map((client): ClientSummary => {
|
||||
const rawClient = client as ClientAnalyticsItem & {
|
||||
client_token?: string;
|
||||
customer_key?: string;
|
||||
last_purchase_date?: string;
|
||||
order_count?: number;
|
||||
quantity_purchased?: number;
|
||||
total_spent?: number;
|
||||
};
|
||||
const rfmClient = rfmByCustomerKey.get(client.customerKey);
|
||||
const orderCount = Number(client.orderCount ?? rawClient.order_count ?? 0);
|
||||
const totalSpent = Number(client.totalSpent ?? rawClient.total_spent ?? 0);
|
||||
const totalItems = Number(client.quantityPurchased ?? rawClient.quantity_purchased ?? 0);
|
||||
const lastPurchaseDate = client.lastPurchaseDate ?? rawClient.last_purchase_date;
|
||||
|
||||
return {
|
||||
customerKey: client.customerKey ?? rawClient.customer_key ?? '',
|
||||
clientToken: client.clientToken ?? rawClient.client_token ?? '',
|
||||
name: client.name,
|
||||
phone: client.phone,
|
||||
totalSpent: client.totalSpent,
|
||||
averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0,
|
||||
totalItems: client.quantityPurchased,
|
||||
orderCount: client.orderCount,
|
||||
lastPurchase: client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : 0,
|
||||
totalSpent,
|
||||
averageTicket: orderCount ? totalSpent / orderCount : 0,
|
||||
totalItems,
|
||||
orderCount,
|
||||
lastPurchase: lastPurchaseDate ? new Date(lastPurchaseDate).getTime() : 0,
|
||||
clientType: rfmClient ? (backendSegmentToClientType[rfmClient.segmentKey] || rfmClient.segmentLabel) : 'Sem análise',
|
||||
rfmScore: rfmClient?.rfmScore || '000',
|
||||
rfmPriority: rfmClient ? getRfmPriority(rfmClient) : 0
|
||||
@@ -144,7 +391,8 @@ const Clients = () => {
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(clientsData.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||
const paginatedData = clientsData.slice(startIndex, startIndex + itemsPerPage);
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
@@ -155,50 +403,138 @@ const Clients = () => {
|
||||
return new Intl.NumberFormat('pt-BR').format(value);
|
||||
};
|
||||
|
||||
const datePresetValue = useMemo(() => {
|
||||
const currentStart = formatDateParam(dateRange.start);
|
||||
const currentEnd = formatDateParam(dateRange.end);
|
||||
const preset = dateFilterPresets.find(option => {
|
||||
const range = option.getRange();
|
||||
return formatDateParam(range.start) === currentStart && formatDateParam(range.end) === currentEnd;
|
||||
});
|
||||
|
||||
return preset?.value || 'custom';
|
||||
}, [dateRange]);
|
||||
|
||||
const updateDatePreset = (value: string) => {
|
||||
if (value === 'custom') return;
|
||||
|
||||
const preset = dateFilterPresets.find(option => option.value === value);
|
||||
if (!preset) return;
|
||||
|
||||
setDateRange(preset.getRange());
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const updateDateStart = (value: string) => {
|
||||
const nextStart = parseLocalDateInput(value);
|
||||
if (!nextStart) return;
|
||||
|
||||
const start = startOfLocalDay(nextStart);
|
||||
const end = dateRange.end < start ? endOfLocalDay(start) : dateRange.end;
|
||||
setDateRange({ start, end });
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const updateDateEnd = (value: string) => {
|
||||
const nextEnd = parseLocalDateInput(value);
|
||||
if (!nextEnd) return;
|
||||
|
||||
const end = endOfLocalDay(nextEnd);
|
||||
const start = dateRange.start > end ? startOfLocalDay(end) : dateRange.start;
|
||||
setDateRange({ start, end });
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const selectClientType = (type: string) => {
|
||||
setClientTypeFilter(current => current === type ? 'all' : type);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const updateMetadataFilter = (key: keyof ClientMetadataFilters, value: string) => {
|
||||
setMetadataFilters(current => ({
|
||||
...current,
|
||||
[key]: value
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const resetMetadataFilters = () => {
|
||||
setMetadataFilters(emptyClientFilters);
|
||||
setClientTypeFilter('all');
|
||||
setSortBy('recent');
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const getSellerOptionLabel = (seller: ClientFilterOptions['sellers'][number]) => {
|
||||
return formatDisplayName(removeTrailingSellerId(seller.name || seller.id));
|
||||
};
|
||||
|
||||
const marketplaceOptions = useMemo(() => {
|
||||
if (!metadataFilters.marketplace || filterOptions.marketplaces.includes(metadataFilters.marketplace)) {
|
||||
return filterOptions.marketplaces;
|
||||
}
|
||||
|
||||
return [metadataFilters.marketplace, ...filterOptions.marketplaces];
|
||||
}, [filterOptions.marketplaces, metadataFilters.marketplace]);
|
||||
|
||||
const salesChannelOptions = useMemo(() => {
|
||||
if (!metadataFilters.canal_venda || filterOptions.salesChannels.includes(metadataFilters.canal_venda)) {
|
||||
return filterOptions.salesChannels;
|
||||
}
|
||||
|
||||
return [metadataFilters.canal_venda, ...filterOptions.salesChannels];
|
||||
}, [filterOptions.salesChannels, metadataFilters.canal_venda]);
|
||||
|
||||
const sellerOptions = useMemo(() => {
|
||||
if (!metadataFilters.seller || filterOptions.sellers.some(seller => seller.value === metadataFilters.seller)) {
|
||||
return filterOptions.sellers;
|
||||
}
|
||||
|
||||
const fallbackSeller = metadataFilters.seller.startsWith('id:')
|
||||
? { value: metadataFilters.seller, id: metadataFilters.seller.slice(3), name: metadataFilters.seller.slice(3) }
|
||||
: { value: metadataFilters.seller, id: '', name: metadataFilters.seller.replace(/^name:/, '') };
|
||||
|
||||
return [fallbackSeller, ...filterOptions.sellers];
|
||||
}, [filterOptions.sellers, metadataFilters.seller]);
|
||||
|
||||
const activeMetadataFilters = useMemo(() => {
|
||||
const filters = [];
|
||||
const selectedSeller = sellerOptions.find(seller => seller.value === metadataFilters.seller);
|
||||
|
||||
if (metadataFilters.marketplace) {
|
||||
filters.push({ key: 'marketplace' as const, label: `Marketplace: ${metadataFilters.marketplace}` });
|
||||
}
|
||||
|
||||
if (metadataFilters.canal_venda) {
|
||||
filters.push({ key: 'canal_venda' as const, label: `Canal: ${metadataFilters.canal_venda}` });
|
||||
}
|
||||
|
||||
if (metadataFilters.seller) {
|
||||
filters.push({ key: 'seller' as const, label: `Vendedor: ${selectedSeller ? getSellerOptionLabel(selectedSeller) : metadataFilters.seller}` });
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [metadataFilters, sellerOptions]);
|
||||
const activeFilterCount = activeMetadataFilters.length +
|
||||
(clientTypeFilter === 'all' ? 0 : 1) +
|
||||
(sortBy === 'recent' ? 0 : 1);
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
const isRefreshing = isLoading && clientAnalytics.length > 0;
|
||||
const shouldShowRfmLoading = isRfmLoading && clientAnalytics.length > 0;
|
||||
const hasWeekdayPattern = purchasePattern?.purchaseWeekdays.some(day => day.value > 0) ?? false;
|
||||
const hasHourPattern = purchasePattern?.purchaseHours.some(hour => hour.value > 0) ?? false;
|
||||
const weekdayRangeLabel = purchasePattern?.weekdayRangeLabel || 'Todo período';
|
||||
const hourRangeLabel = purchasePattern?.hourRangeLabel || 'Últimos 60 dias';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2 text-zinc-900 dark:text-dark-text">Clientes</h1>
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Métricas de engajamento e histórico de consumo dos seus clientes.</p>
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium lg:whitespace-nowrap">Métricas de engajamento e histórico de consumo dos seus clientes.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row flex-wrap gap-3 items-center justify-start xl:justify-end">
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
onChange={(range) => {
|
||||
setDateRange(range);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-4 h-4" />
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value as ClientSortOption);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="appearance-none bg-dark-card border border-dark-border text-dark-text text-sm rounded-xl pl-9 pr-8 py-2.5 focus:outline-none focus:border-brand-primary transition-colors shadow-sm cursor-pointer"
|
||||
>
|
||||
<option value="recent">Mais Recentes</option>
|
||||
<option value="spent_desc">Maior Gasto</option>
|
||||
<option value="spent_asc">Menor Gasto</option>
|
||||
<option value="ticket_desc">Maior Ticket Médio</option>
|
||||
<option value="ticket_asc">Menor Ticket Médio</option>
|
||||
<option value="rfm_priority">Prioridade RFV</option>
|
||||
<option value="items_desc">Mais Produtos</option>
|
||||
<option value="items_asc">Menos Produtos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
@@ -208,64 +544,190 @@ const Clients = () => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full sm:w-64 bg-dark-card border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm"
|
||||
className="w-full bg-dark-card border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const exportData = clientsData.map(client => ({
|
||||
'Nome do Cliente': client.name,
|
||||
'Telefone/WhatsApp': client.phone || 'N/A',
|
||||
'Tipo de Cliente': client.clientType,
|
||||
'RFV': client.rfmScore,
|
||||
'Total Gasto (R$)': client.totalSpent.toFixed(2).replace('.', ','),
|
||||
'Ticket Médio (R$)': client.averageTicket.toFixed(2).replace('.', ','),
|
||||
'Produtos Comprados': client.totalItems,
|
||||
'Total de Pedidos': client.orderCount,
|
||||
'Última Compra': new Date(client.lastPurchase).toLocaleDateString('pt-BR')
|
||||
}));
|
||||
exportToCSV(exportData, `clientes_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
}}
|
||||
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
|
||||
title="Exportar para CSV"
|
||||
>
|
||||
<Download size={16} className="text-brand-primary" />
|
||||
<span className="hidden sm:inline">Exportar</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-4 shadow-sm">
|
||||
<div className="mb-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-zinc-900 dark:text-dark-text">Tipos de Clientes</h2>
|
||||
<p className="text-xs font-semibold text-zinc-500 dark:text-dark-muted">
|
||||
Classificação RFV sincronizada com a página RFV quando disponível.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{clientTypes.map(type => (
|
||||
<div ref={filterMenuRef} className="relative w-full sm:w-auto">
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => selectClientType(type)}
|
||||
title={clientTypeFilter === type ? 'Clique para limpar o filtro' : `Filtrar por ${type}`}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-bold transition-all cursor-pointer hover:-translate-y-0.5 ${
|
||||
clientTypeFilter === type
|
||||
? `${clientTypeStyles[type]} ring-1 ring-current shadow-sm`
|
||||
: clientTypeStyles[type]
|
||||
type="button"
|
||||
onClick={() => setIsFilterMenuOpen(open => !open)}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium shadow-sm transition-colors sm:w-auto ${
|
||||
hasActiveFilters
|
||||
? 'cursor-pointer border-brand-primary bg-brand-primary/10 text-brand-primary'
|
||||
: 'cursor-pointer border-dark-border bg-dark-card text-dark-text hover:border-brand-primary'
|
||||
}`}
|
||||
>
|
||||
<span>{type}</span>
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[10px]">{clientTypeCounts[type] || 0}</span>
|
||||
<Filter className="h-4 w-4" />
|
||||
Filtros
|
||||
{hasActiveFilters && (
|
||||
<span className="rounded-full bg-brand-primary px-1.5 py-0.5 text-[10px] font-bold text-brand-contrast">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{isFilterMenuOpen && (
|
||||
<div className="absolute right-0 top-full z-20 mt-2 w-[min(28rem,calc(100vw-2rem))] rounded-xl border border-dark-border bg-dark-card p-3 shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-dark-text">Filtros</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFilterMenuOpen(false)}
|
||||
className="cursor-pointer rounded-lg p-1 text-dark-muted transition-colors hover:bg-dark-input hover:text-dark-text"
|
||||
aria-label="Fechar filtros"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||
Período
|
||||
<select
|
||||
value={datePresetValue}
|
||||
onChange={(event) => updateDatePreset(event.target.value)}
|
||||
className={`${filterSelectClassName} mt-1`}
|
||||
>
|
||||
<option value="custom">Personalizado</option>
|
||||
{dateFilterPresets.map(preset => (
|
||||
<option key={preset.value} value={preset.value}>{preset.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||
Ordenação
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value as ClientSortOption);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className={`${filterSelectClassName} mt-1`}
|
||||
>
|
||||
<option value="recent">Mais Recentes</option>
|
||||
<option value="spent_desc">Maior Gasto</option>
|
||||
<option value="spent_asc">Menor Gasto</option>
|
||||
<option value="ticket_desc">Maior Ticket Médio</option>
|
||||
<option value="ticket_asc">Menor Ticket Médio</option>
|
||||
<option value="rfm_priority">Prioridade RFV</option>
|
||||
<option value="items_desc">Mais Produtos</option>
|
||||
<option value="items_asc">Menos Produtos</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||
De
|
||||
<input
|
||||
type="date"
|
||||
value={formatDateParam(dateRange.start)}
|
||||
onChange={(event) => updateDateStart(event.target.value)}
|
||||
className={`${filterSelectClassName} mt-1`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||
Até
|
||||
<input
|
||||
type="date"
|
||||
value={formatDateParam(dateRange.end)}
|
||||
onChange={(event) => updateDateEnd(event.target.value)}
|
||||
className={`${filterSelectClassName} mt-1`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||
Marketplace
|
||||
<select
|
||||
value={metadataFilters.marketplace}
|
||||
onChange={(event) => updateMetadataFilter('marketplace', event.target.value)}
|
||||
className={`${filterSelectClassName} mt-1`}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{marketplaceOptions.map(marketplace => (
|
||||
<option key={marketplace} value={marketplace}>{marketplace}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||
Canal de venda
|
||||
<select
|
||||
value={metadataFilters.canal_venda}
|
||||
onChange={(event) => updateMetadataFilter('canal_venda', event.target.value)}
|
||||
className={`${filterSelectClassName} mt-1`}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{salesChannelOptions.map(channel => (
|
||||
<option key={channel} value={channel}>{channel}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||
Vendedor
|
||||
<select
|
||||
value={metadataFilters.seller}
|
||||
onChange={(event) => updateMetadataFilter('seller', event.target.value)}
|
||||
className={`${filterSelectClassName} mt-1`}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{!sellerOptions.length && (
|
||||
<option value="" disabled>Nenhum vendedor encontrado</option>
|
||||
)}
|
||||
{sellerOptions.map(seller => (
|
||||
<option key={seller.value} value={seller.value}>{getSellerOptionLabel(seller)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||
Tipo de cliente
|
||||
<select
|
||||
value={clientTypeFilter}
|
||||
onChange={(event) => selectClientType(event.target.value)}
|
||||
className={`${filterSelectClassName} mt-1`}
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
{clientTypes.map(type => (
|
||||
<option key={type} value={type}>
|
||||
{type} ({clientTypeCounts[type] || 0})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between border-t border-dark-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetMetadataFilters}
|
||||
disabled={!hasActiveFilters}
|
||||
className="cursor-pointer text-sm font-bold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Limpar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFilterMenuOpen(false)}
|
||||
className="cursor-pointer rounded-xl bg-brand-primary px-4 py-2 text-sm font-bold text-brand-contrast transition-opacity hover:opacity-90"
|
||||
>
|
||||
Aplicar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
||||
<RefreshStatus isRefreshing={isRefreshing} />
|
||||
|
||||
{isLoading && clientAnalytics.length === 0 ? (
|
||||
<ClientsTableSkeleton />
|
||||
) : (
|
||||
<div className={`bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-zinc-50 dark:bg-dark-header border-b border-zinc-100 dark:border-dark-border text-zinc-500 dark:text-dark-muted">
|
||||
@@ -281,8 +743,11 @@ const Clients = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||
{paginatedData.map((client, index) => (
|
||||
<tr key={client.name} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
|
||||
{paginatedData.map((client, index) => {
|
||||
const clientTypeStyle = clientTypeStyles[client.clientType] || clientTypeStyles.Perdido;
|
||||
|
||||
return (
|
||||
<tr key={client.customerKey} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
|
||||
<td className="px-6 py-2.5">
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold bg-zinc-100 dark:bg-dark-border text-zinc-500 dark:text-dark-muted">
|
||||
{startIndex + index + 1}
|
||||
@@ -290,21 +755,36 @@ const Clients = () => {
|
||||
</td>
|
||||
<td className="px-6 py-2.5 font-semibold text-zinc-900 dark:text-dark-text">{client.name}</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<span className={`inline-flex rounded-full border px-3 py-1 text-xs font-bold ${clientTypeStyles[client.clientType] || clientTypeStyles.Perdido}`}>
|
||||
{client.clientType}
|
||||
</span>
|
||||
{shouldShowRfmLoading ? (
|
||||
<span className="skeleton inline-flex h-7 w-24 rounded-full" />
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex rounded-full border px-3 py-1 text-xs font-bold"
|
||||
style={clientTypePillStyle(clientTypeStyle)}
|
||||
>
|
||||
{client.clientType}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{client.rfmScore.split('').map((score, scoreIndex) => (
|
||||
<span
|
||||
key={`${client.name}-${scoreIndex}`}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-dark-border bg-dark-input text-[11px] font-bold text-dark-text"
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{shouldShowRfmLoading ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{[0, 1, 2].map(item => (
|
||||
<span key={`rfm-score-loading-${client.customerKey}-${item}`} className="skeleton h-6 w-6 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
{client.rfmScore.split('').map((score, scoreIndex) => (
|
||||
<span
|
||||
key={`${client.name}-${scoreIndex}`}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-dark-border bg-dark-input text-[11px] font-bold text-dark-text"
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(client.totalSpent)}</td>
|
||||
<td className="px-6 py-2.5 text-zinc-700 dark:text-dark-text font-semibold">{formatCurrency(client.averageTicket)}</td>
|
||||
@@ -313,7 +793,7 @@ const Clients = () => {
|
||||
</td>
|
||||
<td className="px-6 py-2.5 text-right">
|
||||
<Link
|
||||
to={`/clients/${encodeURIComponent(client.name)}`}
|
||||
to={`/clients/${encodeURIComponent(client.clientToken)}`}
|
||||
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
Ver detalhes
|
||||
@@ -321,54 +801,113 @@ const Clients = () => {
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<div className="px-6 py-4 border-t border-zinc-100 dark:border-dark-border flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-dark-muted">
|
||||
<span>Mostrar</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
<span>itens por página</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-zinc-500 dark:text-dark-muted">
|
||||
Mostrando {clientsData.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + itemsPerPage, clientsData.length)} de {clientsData.length} clientes
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
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"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages || totalPages === 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"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
<PaginationControls
|
||||
totalItems={clientsData.length}
|
||||
currentPage={safeCurrentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={itemsPerPage}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
itemLabel="clientes"
|
||||
pageSizeLabel="itens por página"
|
||||
startIndex={startIndex}
|
||||
endIndex={Math.min(startIndex + itemsPerPage, clientsData.length)}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(pageSize) => {
|
||||
setItemsPerPage(pageSize);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPatternLoading ? (
|
||||
<ClientPurchasePatternSkeleton />
|
||||
) : (
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Padrão de Compra</h2>
|
||||
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Quando os clientes costumam comprar, separando histórico de data e horário confiável.</p>
|
||||
</div>
|
||||
</div>
|
||||
</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-5 flex items-start justify-between gap-4">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Dia</h3>
|
||||
<span className="shrink-0 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-zinc-500 dark:border-dark-border dark:bg-dark-input dark:text-dark-muted">
|
||||
{weekdayRangeLabel}
|
||||
</span>
|
||||
</div>
|
||||
{hasWeekdayPattern ? (
|
||||
<div className="h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={purchasePattern?.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]} animationDuration={850} animationEasing="ease-out">
|
||||
{(purchasePattern?.purchaseWeekdays || []).map(day => (
|
||||
<Cell key={`clients-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 registradas.
|
||||
</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 items-start justify-between gap-4">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Horário</h3>
|
||||
<span className="shrink-0 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-zinc-500 dark:border-dark-border dark:bg-dark-input dark:text-dark-muted">
|
||||
{hourRangeLabel}
|
||||
</span>
|
||||
</div>
|
||||
{hasHourPattern ? (
|
||||
<div className="h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={purchasePattern?.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]} animationDuration={850} animationEasing="ease-out">
|
||||
{(purchasePattern?.purchaseHours || []).map(hour => (
|
||||
<Cell key={`clients-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.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
1342
src/pages/Cutting.tsx
Normal file
1342
src/pages/Cutting.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,207 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useOutletContext, useNavigate } from 'react-router-dom';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import { DollarSign, Loader2, ShoppingCart, TrendingUp } from 'lucide-react';
|
||||
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Line } 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';
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -25,24 +212,83 @@ type CustomTooltipProps = {
|
||||
payload?: ChartTooltipPayload[];
|
||||
label?: string;
|
||||
isCurrency?: boolean;
|
||||
valueLabel?: string;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, isCurrency }: CustomTooltipProps) => {
|
||||
const CustomTooltip = ({ active, payload, label, isCurrency, valueLabel }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const color = payload[0].payload?.fill || payload[0].color || '#9ECAE1';
|
||||
const color = payload[0].payload?.fill || payload[0].stroke || payload[0].color || 'var(--chart-detail-bar)';
|
||||
const displayLabel = label || payload[0].name;
|
||||
const value = isCurrency ? formatCurrency(payload[0].value) : payload[0].value;
|
||||
const valueLabel = isCurrency ? 'Receita:' : 'Vendas:';
|
||||
const displayValueLabel = valueLabel || (isCurrency ? 'Receita:' : 'Vendas:');
|
||||
return (
|
||||
<div className="bg-[#141414] p-3 rounded-xl shadow-lg border-none">
|
||||
<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 }}>{displayLabel}</p>
|
||||
<p className="text-[#ededed] m-0">{valueLabel} {value}</p>
|
||||
<p className="m-0" style={{ color: 'var(--chart-tooltip-text)' }}>{displayValueLabel} {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<{
|
||||
@@ -54,10 +300,14 @@ const Dashboard = () => {
|
||||
}>();
|
||||
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) => {
|
||||
const loadDashboardMetrics = useCallback(async (range: DateRange, options?: { force?: boolean }) => {
|
||||
setIsMetricsLoading(true);
|
||||
const metrics = await fetchDashboardAnalytics(range);
|
||||
const metrics = await fetchDashboardAnalytics(range, options);
|
||||
setServerMetrics(metrics);
|
||||
setIsMetricsLoading(false);
|
||||
}, []);
|
||||
@@ -72,20 +322,215 @@ const Dashboard = () => {
|
||||
if (refreshInterval === 0) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
void loadDashboardMetrics(dateRange);
|
||||
void loadDashboardMetrics(dateRange, { force: true });
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [dateRange, loadDashboardMetrics, refreshInterval]);
|
||||
|
||||
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct } = useMemo(() => {
|
||||
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]);
|
||||
}
|
||||
});
|
||||
|
||||
return colorMap;
|
||||
}, [chartOrdersBySeller, chartRevenueBySeller]);
|
||||
|
||||
const sellerMetricConfig = sellerMetricOptions.find(option => option.key === sellerMetric) || sellerMetricOptions[0];
|
||||
|
||||
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)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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 chartKeys = isHourly
|
||||
? Array.from({ length: 24 }, (_, hour) => formatHourKey(hour))
|
||||
: [...valuesByDate.keys()].sort((dateA, dateB) => dateA.localeCompare(dateB));
|
||||
|
||||
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);
|
||||
void loadDashboardMetrics(dateRange, { force: true });
|
||||
};
|
||||
const shouldShowSkeleton = isMetricsLoading && !serverMetrics;
|
||||
const isRefreshing = isMetricsLoading && Boolean(serverMetrics);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -103,13 +548,12 @@ const Dashboard = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isMetricsLoading && (
|
||||
<div className="inline-flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||
Atualizando indicadores
|
||||
</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">
|
||||
@@ -117,8 +561,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="p-3 bg-emerald-500/10 rounded-xl">
|
||||
<DollarSign className="w-6 h-6 text-emerald-500" />
|
||||
<div className="rounded-xl border p-3" style={kpiIconStyle('#52DFA0')}>
|
||||
<DollarSign className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,8 +573,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="p-3 bg-blue-500/10 rounded-xl">
|
||||
<ShoppingCart className="w-6 h-6 text-blue-500" />
|
||||
<div className="rounded-xl border p-3" style={kpiIconStyle('#82B8FF')}>
|
||||
<ShoppingCart className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,29 +585,278 @@ 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="p-3 bg-purple-500/10 rounded-xl">
|
||||
<TrendingUp className="w-6 h-6 text-purple-500" />
|
||||
<div className="rounded-xl border p-3" style={kpiIconStyle('#B992FF')}>
|
||||
<TrendingUp className="w-6 h-6" />
|
||||
</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="#222222" vertical={false} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name" stroke="#888888" fontSize={10} tickLine={false} axisLine={false}
|
||||
dataKey="name" stroke={CHART_AXIS_COLOR} fontSize={10} tickLine={false} axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#222222' }} />
|
||||
<YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
|
||||
<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} style={{ cursor: 'pointer' }} />
|
||||
<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' }}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
@@ -184,12 +877,20 @@ 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" stroke="none" 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" 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} style={{ cursor: 'pointer' }} />
|
||||
<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)}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip isCurrency={true} />} cursor={{ fill: '#222222' }} />
|
||||
<Tooltip content={<CustomTooltip isCurrency={true} />} cursor={{ fill: CHART_CURSOR_COLOR }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -203,6 +904,8 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,29 +1,187 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { login } from '../dataService';
|
||||
import { getLoginConfig, login } from '../dataService';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (
|
||||
container: HTMLElement,
|
||||
options: {
|
||||
sitekey: string;
|
||||
callback: (token: string) => void;
|
||||
'expired-callback': () => void;
|
||||
'error-callback': () => void;
|
||||
theme: 'dark' | 'light' | 'auto';
|
||||
}
|
||||
) => string;
|
||||
reset: (widgetId?: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const turnstileScriptId = 'turnstile-api-script';
|
||||
const securityConfigLoadMessage = 'Não foi possível carregar as configurações de segurança. Atualize a página e tente novamente.';
|
||||
const securityUnavailableMessage = 'Não foi possível carregar a verificação de segurança. Atualize a página e tente novamente.';
|
||||
const securityConfigurationMessage = 'Verificação de segurança indisponível. Entre em contato com o administrador.';
|
||||
const securityRequiredMessage = 'Conclua a verificação de segurança para continuar.';
|
||||
const securityFailedMessage = 'Não foi possível validar a verificação de segurança. Atualize a página e tente novamente.';
|
||||
const turnstileSiteKeyPattern = /^[0-9]x[0-9A-Za-z_-]{20,}$/;
|
||||
|
||||
const Login = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [captchaToken, setCaptchaToken] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isConfigLoading, setIsConfigLoading] = useState(true);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [captchaRequired, setCaptchaRequired] = useState(false);
|
||||
const [captchaReady, setCaptchaReady] = useState(true);
|
||||
const captchaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const captchaWidgetIdRef = useRef<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const hasValidSiteKey = turnstileSiteKeyPattern.test(turnstileSiteKey);
|
||||
const captchaEnabled = Boolean(captchaRequired && hasValidSiteKey);
|
||||
const securityMisconfigured = captchaRequired && !hasValidSiteKey;
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadLoginConfig = async () => {
|
||||
try {
|
||||
const config = await getLoginConfig();
|
||||
if (!isMounted) return;
|
||||
|
||||
setCaptchaRequired(config.captchaRequired);
|
||||
setTurnstileSiteKey(config.turnstileSiteKey.trim());
|
||||
setCaptchaReady(!config.captchaRequired);
|
||||
if (config.captchaRequired && !config.captchaConfigured) {
|
||||
setError(securityConfigurationMessage);
|
||||
}
|
||||
} catch {
|
||||
if (!isMounted) return;
|
||||
setCaptchaReady(false);
|
||||
setError(securityConfigLoadMessage);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsConfigLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadLoginConfig();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasValidSiteKey || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
|
||||
|
||||
const siteKey = turnstileSiteKey;
|
||||
|
||||
const renderCaptcha = () => {
|
||||
if (!window.turnstile || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
|
||||
|
||||
try {
|
||||
captchaWidgetIdRef.current = window.turnstile.render(captchaContainerRef.current, {
|
||||
sitekey: siteKey,
|
||||
theme: 'dark',
|
||||
callback: (token) => {
|
||||
setCaptchaToken(token);
|
||||
setCaptchaReady(true);
|
||||
},
|
||||
'expired-callback': () => {
|
||||
setCaptchaToken('');
|
||||
setCaptchaReady(true);
|
||||
},
|
||||
'error-callback': () => {
|
||||
setCaptchaToken('');
|
||||
setCaptchaReady(false);
|
||||
setError(securityUnavailableMessage);
|
||||
},
|
||||
});
|
||||
setCaptchaReady(true);
|
||||
} catch {
|
||||
setCaptchaToken('');
|
||||
setCaptchaReady(false);
|
||||
setError(securityConfigurationMessage);
|
||||
}
|
||||
};
|
||||
|
||||
if (window.turnstile) {
|
||||
renderCaptcha();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingScript = document.getElementById(turnstileScriptId);
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener('load', renderCaptcha, { once: true });
|
||||
return () => existingScript.removeEventListener('load', renderCaptcha);
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = turnstileScriptId;
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.addEventListener('load', renderCaptcha, { once: true });
|
||||
script.addEventListener('error', () => {
|
||||
setCaptchaReady(false);
|
||||
setError(securityUnavailableMessage);
|
||||
}, { once: true });
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => script.removeEventListener('load', renderCaptcha);
|
||||
}, [hasValidSiteKey, turnstileSiteKey]);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (securityMisconfigured) {
|
||||
setError(securityConfigurationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (captchaEnabled && !captchaToken) {
|
||||
setError(securityRequiredMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const success = await login(email, password);
|
||||
if (success) {
|
||||
const result = await login(email, password, captchaToken);
|
||||
if (result === 'success') {
|
||||
navigate('/graph');
|
||||
} else {
|
||||
} else if (result === 'captcha_failed') {
|
||||
setError(captchaEnabled ? securityFailedMessage : securityConfigurationMessage);
|
||||
if (captchaEnabled) {
|
||||
setCaptchaToken('');
|
||||
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
||||
}
|
||||
} else if (result === 'invalid_credentials') {
|
||||
setError('E-mail ou senha incorretos.');
|
||||
if (captchaEnabled) {
|
||||
setCaptchaToken('');
|
||||
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
||||
}
|
||||
} else {
|
||||
setError('Erro ao conectar ao servidor.');
|
||||
if (captchaEnabled) {
|
||||
setCaptchaToken('');
|
||||
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError('Erro ao conectar ao servidor.');
|
||||
if (captchaEnabled) {
|
||||
setCaptchaToken('');
|
||||
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -64,14 +222,23 @@ const Login = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm font-medium">{error}</p>}
|
||||
{(captchaEnabled || securityMisconfigured) && (
|
||||
<div className="min-h-[70px] rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
||||
{captchaEnabled && <div ref={captchaContainerRef} className="flex justify-center" />}
|
||||
{(securityMisconfigured || !captchaReady) && (
|
||||
<p className="mt-2 text-center text-xs font-medium text-red-400">Verificação de segurança indisponível.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-500 text-sm font-medium" role="alert">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isConfigLoading || securityMisconfigured || !captchaReady || (captchaEnabled && !captchaToken)}
|
||||
className="w-full bg-brand-primary hover:bg-opacity-90 hover:scale-[1.02] active:scale-[0.98] text-zinc-900 font-bold py-3 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 mt-4 cursor-pointer"
|
||||
>
|
||||
{isLoading ? 'Entrando...' : 'Entrar'}
|
||||
{isLoading || isConfigLoading ? 'Entrando...' : 'Entrar'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
439
src/pages/PlanningIssues.tsx
Normal file
439
src/pages/PlanningIssues.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { AlertTriangle, CheckCircle2, Eye, PackageSearch, Pencil, Search } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import SkuPlanningModal from '../components/SkuPlanningModal';
|
||||
import { classifyCutFamily, CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting';
|
||||
import { fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
|
||||
import { getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
import { parseProductName } from '../productParsing';
|
||||
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
|
||||
|
||||
type IssueKey = 'review_type' | 'missing_cut_family' | 'missing_color' | 'missing_size' | 'missing_yield';
|
||||
type IssueFilter = 'all' | IssueKey;
|
||||
|
||||
type PlanningIssueRow = ProductAnalyticsItem & {
|
||||
productType: ProductTypeKey;
|
||||
color: string;
|
||||
size: string;
|
||||
familyKey: CutFamilyKey | '';
|
||||
familyLabel: string;
|
||||
issues: IssueKey[];
|
||||
priorityScore: number;
|
||||
hasOverride: boolean;
|
||||
};
|
||||
|
||||
const issueLabels: Record<IssueKey, string> = {
|
||||
review_type: 'Tipo',
|
||||
missing_cut_family: 'Família',
|
||||
missing_color: 'Cor',
|
||||
missing_size: 'Tamanho',
|
||||
missing_yield: 'Rendimento'
|
||||
};
|
||||
|
||||
const issueHelp: Record<IssueKey, string> = {
|
||||
review_type: 'O classificador não conseguiu identificar o tipo do SKU.',
|
||||
missing_cut_family: 'SKU de vestuário não caiu em uma família de corte confiável.',
|
||||
missing_color: 'SKU de vestuário não tem cor clara para planejamento.',
|
||||
missing_size: 'SKU de vestuário não tem tamanho claro para planejamento.',
|
||||
missing_yield: 'A família existe, mas ainda falta rendimento em unidades por rolo.'
|
||||
};
|
||||
|
||||
const issueFilters: Array<{ value: IssueFilter; label: string }> = [
|
||||
{ value: 'all', label: 'Todos' },
|
||||
{ value: 'review_type', label: issueLabels.review_type },
|
||||
{ value: 'missing_cut_family', label: issueLabels.missing_cut_family },
|
||||
{ value: 'missing_color', label: issueLabels.missing_color },
|
||||
{ value: 'missing_size', label: issueLabels.missing_size },
|
||||
{ value: 'missing_yield', label: issueLabels.missing_yield }
|
||||
];
|
||||
|
||||
const familyLabels = new Map<CutFamilyKey, string>([
|
||||
...CUT_FAMILY_RULES.map(rule => [rule.key, `${rule.materialLabel} · ${rule.label}`] as const),
|
||||
['OUTROS', 'Sem regra']
|
||||
]);
|
||||
|
||||
const formatNumber = (value: number, maximumFractionDigits = 0) => (
|
||||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
|
||||
);
|
||||
|
||||
const formatCurrency = (value: number) => (
|
||||
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
|
||||
);
|
||||
|
||||
const buildIssues = (
|
||||
product: ProductAnalyticsItem,
|
||||
settings: CuttingSettings
|
||||
): PlanningIssueRow => {
|
||||
const override = settings.productOverrides[product.id];
|
||||
const metadata = parseProductName(product.name);
|
||||
const productType = resolveProductType(product.name, override);
|
||||
const familyKey = override?.familyKey || classifyCutFamily(metadata.baseName).key;
|
||||
const color = override?.color || metadata.color;
|
||||
const size = (override?.size || metadata.size).toUpperCase();
|
||||
const issues: IssueKey[] = [];
|
||||
|
||||
if (productType === 'unknown') issues.push('review_type');
|
||||
|
||||
if (productType === 'finished_apparel') {
|
||||
if (familyKey === 'OUTROS') issues.push('missing_cut_family');
|
||||
if (!color) issues.push('missing_color');
|
||||
if (!size) issues.push('missing_size');
|
||||
if (familyKey !== 'OUTROS' && !settings.familyYields[familyKey]) issues.push('missing_yield');
|
||||
}
|
||||
|
||||
return {
|
||||
...product,
|
||||
productType,
|
||||
color,
|
||||
size,
|
||||
familyKey,
|
||||
familyLabel: familyLabels.get(familyKey) || '-',
|
||||
issues,
|
||||
priorityScore: (product.quantitySold * 2) + (product.revenue / 100),
|
||||
hasOverride: Boolean(override)
|
||||
};
|
||||
};
|
||||
|
||||
const PlanningIssuesSkeleton = () => (
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm" aria-label="Carregando dados pendentes">
|
||||
<div className="border-b border-dark-border p-4">
|
||||
<div className="grid grid-cols-[120px_1.4fr_150px_170px_130px_130px_120px] gap-5">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map(item => <div key={item} className="skeleton h-3" />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-dark-border">
|
||||
{[0, 1, 2, 3, 4, 5].map(row => (
|
||||
<div key={row} className="grid grid-cols-[120px_1.4fr_150px_170px_130px_130px_120px] gap-5 px-6 py-4">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map(item => <div key={item} className="skeleton h-4" />)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PlanningIssues = () => {
|
||||
const { dateRange, setDateRange } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void
|
||||
}>();
|
||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [settings, setSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [issueFilter, setIssueFilter] = useState<IssueFilter>('all');
|
||||
const [editingProduct, setEditingProduct] = useState<PlanningIssueRow | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
const [productData, planningSettings] = await Promise.all([
|
||||
fetchProductAnalytics(dateRange),
|
||||
fetchCuttingSettings()
|
||||
]);
|
||||
|
||||
if (isMounted) {
|
||||
setProducts(productData);
|
||||
setSettings(planningSettings);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [dateRange]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||
|
||||
return products
|
||||
.map(product => buildIssues(product, settings))
|
||||
.filter(row => row.issues.length > 0)
|
||||
.filter(row => issueFilter === 'all' || row.issues.includes(issueFilter))
|
||||
.filter(row => {
|
||||
if (!normalizedSearch) return true;
|
||||
const typeLabel = getProductTypeConfig(row.productType).label.toLowerCase();
|
||||
return (
|
||||
row.id.toLowerCase().includes(normalizedSearch) ||
|
||||
row.name.toLowerCase().includes(normalizedSearch) ||
|
||||
row.color.toLowerCase().includes(normalizedSearch) ||
|
||||
row.size.toLowerCase().includes(normalizedSearch) ||
|
||||
row.familyLabel.toLowerCase().includes(normalizedSearch) ||
|
||||
typeLabel.includes(normalizedSearch)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (b.issues.length !== a.issues.length) return b.issues.length - a.issues.length;
|
||||
return b.priorityScore - a.priorityScore;
|
||||
});
|
||||
}, [issueFilter, products, searchTerm, settings]);
|
||||
|
||||
const issueCounts = useMemo(() => {
|
||||
const counts = new Map<IssueKey, number>();
|
||||
products
|
||||
.map(product => buildIssues(product, settings))
|
||||
.forEach(row => row.issues.forEach(issue => counts.set(issue, (counts.get(issue) || 0) + 1)));
|
||||
return counts;
|
||||
}, [products, settings]);
|
||||
|
||||
const saveProductOverride = async (productId: string, override: CutProductOverride | null) => {
|
||||
const productOverrides = { ...settings.productOverrides };
|
||||
if (override) {
|
||||
productOverrides[productId] = override;
|
||||
} else {
|
||||
delete productOverrides[productId];
|
||||
}
|
||||
|
||||
const nextSettings = { ...settings, productOverrides };
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const savedSettings = await saveCuttingSettings(nextSettings);
|
||||
setSettings(savedSettings);
|
||||
setEditingProduct(null);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(rows.length / itemsPerPage);
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||
const paginatedRows = rows.slice(startIndex, startIndex + itemsPerPage);
|
||||
const isRefreshing = isLoading && products.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
|
||||
<div>
|
||||
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Dados Pendentes</h1>
|
||||
<p className="font-medium text-zinc-500 dark:text-dark-muted">
|
||||
Fila principal para revisar tipo, cor, tamanho, família e rendimento dos SKUs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center 2xl:justify-end">
|
||||
<Link
|
||||
to="/registrations"
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
|
||||
>
|
||||
<PackageSearch className="h-4 w-4 text-brand-primary" />
|
||||
Cadastros
|
||||
</Link>
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
onChange={(range) => {
|
||||
setDateRange(range);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RefreshStatus isRefreshing={isRefreshing} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 xl:grid-cols-5">
|
||||
{issueFilters.filter(option => option.value !== 'all').map(option => {
|
||||
const count = issueCounts.get(option.value as IssueKey) || 0;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIssueFilter(option.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className={`rounded-2xl border p-4 text-left transition-colors cursor-pointer ${
|
||||
issueFilter === option.value
|
||||
? 'border-brand-primary/45 bg-brand-primary/10'
|
||||
: 'border-dark-border bg-dark-card hover:border-brand-primary/35'
|
||||
}`}
|
||||
title={issueHelp[option.value as IssueKey]}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{option.label}</span>
|
||||
<AlertTriangle className={count ? 'h-4 w-4 text-amber-300' : 'h-4 w-4 text-emerald-300'} />
|
||||
</div>
|
||||
<div className="mt-3 text-2xl font-bold text-dark-text">{formatNumber(count)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[minmax(360px,1fr)_auto] xl:items-center">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="relative w-full md:w-96">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => {
|
||||
setSearchTerm(event.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="Buscar SKU, produto, cor, tipo..."
|
||||
className="h-10 w-full rounded-xl border border-dark-border bg-dark-input pl-10 pr-3 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={issueFilter}
|
||||
onChange={(event) => {
|
||||
setIssueFilter(event.target.value as IssueFilter);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
{issueFilters.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-dark-muted">
|
||||
{formatNumber(rows.length)} SKUs pendentes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && !products.length ? (
|
||||
<PlanningIssuesSkeleton />
|
||||
) : rows.length ? (
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1180px] table-fixed text-left text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[380px]" />
|
||||
<col className="w-[150px]" />
|
||||
<col className="w-[180px]" />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[120px]" />
|
||||
</colgroup>
|
||||
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">SKU</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Produto</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Tipo</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Pendências</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Família</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Vendido</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Receita</th>
|
||||
<th className="px-4 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{paginatedRows.map(row => (
|
||||
<tr key={row.id} className="transition-colors hover:bg-dark-input/50">
|
||||
<td className="px-6 py-2.5 font-mono text-[11px] text-dark-muted">#{row.id}</td>
|
||||
<td className="max-w-0 px-6 py-2.5">
|
||||
<div className="truncate font-semibold text-dark-text" title={row.name}>{row.name}</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 text-[10px] font-semibold text-dark-muted">
|
||||
<span className="truncate">Cor: {row.color || '-'}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">Tam.: {row.size || '-'}</span>
|
||||
{row.hasOverride && <span className="rounded-full border border-brand-primary/25 bg-brand-primary/10 px-2 py-0.5 text-brand-primary">manual</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<ProductTypeBadge type={row.productType} />
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{row.issues.map(issue => (
|
||||
<span
|
||||
key={issue}
|
||||
className="inline-flex rounded-full border border-amber-400/25 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
|
||||
title={issueHelp[issue]}
|
||||
>
|
||||
{issueLabels[issue]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-2.5 text-xs font-bold text-dark-text">{row.familyLabel}</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-dark-text">{formatNumber(row.quantitySold)} un.</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-brand-primary">{formatCurrency(row.revenue)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProduct(row)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border cursor-pointer"
|
||||
title={`Editar planejamento do SKU ${row.id}`}
|
||||
aria-label={`Editar planejamento do SKU ${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<Link
|
||||
to={`/products/${row.id}`}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80 cursor-pointer"
|
||||
title={`Ver SKU ${row.id}`}
|
||||
aria-label={`Ver SKU ${row.id}`}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<PaginationControls
|
||||
totalItems={rows.length}
|
||||
currentPage={safeCurrentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={itemsPerPage}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
itemLabel="SKUs"
|
||||
pageSizeLabel="por página"
|
||||
startIndex={startIndex}
|
||||
endIndex={Math.min(startIndex + itemsPerPage, rows.length)}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemsPerPage(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dark-border bg-dark-card px-6 py-16 text-center shadow-sm">
|
||||
{products.length ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-10 w-10 text-emerald-300" />
|
||||
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum dado pendente neste filtro.</p>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Ajuste o filtro ou o período para revisar outros SKUs.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackageSearch className="h-10 w-10 text-dark-muted" />
|
||||
<p className="mt-4 text-sm font-bold text-dark-text">Sem produtos no período.</p>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Altere o intervalo de datas para carregar a fila.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingProduct && (
|
||||
<SkuPlanningModal
|
||||
product={editingProduct}
|
||||
override={settings.productOverrides[editingProduct.id]}
|
||||
isSaving={isSaving}
|
||||
onClose={() => setEditingProduct(null)}
|
||||
onSave={(override) => saveProductOverride(editingProduct.id, override)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanningIssues;
|
||||
@@ -1,58 +1,228 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, Package, DollarSign } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { Package, DollarSign, Pencil, ReceiptText, Warehouse } from 'lucide-react';
|
||||
import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import BackButton from '../components/BackButton';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import type { OrderData, DateRange } from '../types';
|
||||
import { buildProductDetailsMetrics } from '../analytics/products';
|
||||
import SkuPlanningModal from '../components/SkuPlanningModal';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
|
||||
import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
|
||||
import { parseProductName } from '../productParsing';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
|
||||
import { getProductTypeConfig, resolveProductType } from '../productClassification';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
|
||||
const CHART_GRID_COLOR = 'var(--chart-grid)';
|
||||
const CHART_AXIS_COLOR = 'var(--chart-axis)';
|
||||
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 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 }>;
|
||||
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 }: CustomTooltipProps) => {
|
||||
const CustomTooltip = ({ active, payload, label, metric, formatCurrency, formatNumber, isHourly, dateBucket }: CustomTooltipProps) => {
|
||||
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="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
|
||||
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>
|
||||
);
|
||||
}
|
||||
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, ordersData, isDataLoading } = useOutletContext<{
|
||||
const { dateRange, setDateRange } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void,
|
||||
ordersData: OrderData[],
|
||||
isDataLoading: boolean,
|
||||
refreshInterval: number,
|
||||
setRefreshInterval: (interval: number) => void,
|
||||
loadData: (showLoading?: boolean) => void
|
||||
setDateRange: (range: DateRange) => void
|
||||
}>();
|
||||
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
|
||||
const [composition, setComposition] = useState<ProductComposition | null>(null);
|
||||
const [isCompositionLoading, setIsCompositionLoading] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
|
||||
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
|
||||
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||
const [isPlanningModalOpen, setIsPlanningModalOpen] = useState(false);
|
||||
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
|
||||
|
||||
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
|
||||
return buildProductDetailsMetrics(ordersData, id, dateRange);
|
||||
}, [id, dateRange, ordersData]);
|
||||
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 loadProductDetails = async () => {
|
||||
if (!id) {
|
||||
if (isMounted) {
|
||||
setDetails(null);
|
||||
setComposition(null);
|
||||
setIsLoading(false);
|
||||
setIsCompositionLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setIsCompositionLoading(true);
|
||||
const [productDetails, productComposition] = await Promise.all([
|
||||
fetchProductDetailsAnalytics(id, dateRange),
|
||||
fetchProductComposition(id)
|
||||
]);
|
||||
|
||||
if (isMounted) {
|
||||
setDetails(productDetails);
|
||||
setComposition(productComposition);
|
||||
setIsLoading(false);
|
||||
setIsCompositionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadProductDetails();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [dateRange, id]);
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
};
|
||||
|
||||
if (!productInfo && isDataLoading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando produto...</p>
|
||||
</div>
|
||||
);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !details) {
|
||||
return <ProductDetailsSkeleton />;
|
||||
}
|
||||
|
||||
if (!productInfo) {
|
||||
if (!details?.productInfo) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Produto não encontrado.</p>
|
||||
@@ -61,14 +231,112 @@ 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);
|
||||
|
||||
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">
|
||||
<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>
|
||||
<BackButton fallbackTo="/products" />
|
||||
|
||||
<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">
|
||||
@@ -77,6 +345,19 @@ const ProductDetails = () => {
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest">ID: #{productInfo.id}</div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{productInfo.name}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ProductTypeBadge type={productType} />
|
||||
<span className="text-xs font-semibold text-dark-muted">{productTypeConfig.description}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPlanningModalOpen(true)}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
|
||||
title={`Editar planejamento do SKU ${productInfo.id}`}
|
||||
aria-label={`Editar planejamento do SKU ${productInfo.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,11 +367,15 @@ const ProductDetails = () => {
|
||||
onChange={setDateRange}
|
||||
/> </div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<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-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">{totalSold}</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>
|
||||
</div>
|
||||
<div className="p-3 bg-brand-primary/10 rounded-xl text-brand-primary">
|
||||
<Package size={24} />
|
||||
@@ -105,28 +390,284 @@ 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>
|
||||
|
||||
<section className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||
<div className="mb-5">
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
|
||||
{composition && (
|
||||
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
|
||||
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isCompositionLoading ? (
|
||||
<div className="flex h-24 items-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">Carregando composição…</div>
|
||||
) : !composition ? (
|
||||
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-dark-border bg-dark-input/30 text-sm font-semibold text-zinc-500 dark:text-dark-muted">
|
||||
Nenhuma composição sincronizada para este produto.
|
||||
</div>
|
||||
) : composition.components.length === 0 ? (
|
||||
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-dark-border bg-dark-input/30 text-sm font-semibold text-zinc-500 dark:text-dark-muted">
|
||||
Esta composição não possui insumos.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-dark-border">
|
||||
<table className="w-full min-w-[640px] text-left text-sm">
|
||||
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Produto / insumo</th>
|
||||
<th className="px-4 py-3">SKU</th>
|
||||
<th className="px-4 py-3 text-right">Quantidade por unidade</th>
|
||||
<th className="px-4 py-3">Unidade</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{composition.components.map(component => (
|
||||
<tr key={component.id} className="text-dark-text">
|
||||
<td className="px-4 py-3 font-semibold">
|
||||
{component.productId ? (
|
||||
<Link to={`/products/${component.productId}`} className="text-brand-primary hover:underline">
|
||||
{component.componentName}
|
||||
</Link>
|
||||
) : component.componentName}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-dark-muted">{component.componentSku || '—'}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold">{formatNumber(component.quantityPerUnit)}</td>
|
||||
<td className="px-4 py-3 text-dark-muted">{component.unit || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||
<div className="mb-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>
|
||||
) : (
|
||||
<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} />
|
||||
<XAxis
|
||||
dataKey="date" stroke={CHART_AXIS_COLOR} fontSize={10} tickLine={false} axisLine={false}
|
||||
minTickGap={18}
|
||||
tickFormatter={(value) => (
|
||||
isHourlyChart ? String(value) : formatDateBucketLabel(String(value), dateBucket)
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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">Volume de Vendas por Data</h3>
|
||||
<div className="h-[400px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date" stroke="#888888" fontSize={10} tickLine={false} axisLine={false}
|
||||
interval={0}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<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 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">
|
||||
{variantBreakdown.map(variant => {
|
||||
const width = maxVariantQuantity ? Math.max(4, (variant.quantitySold / maxVariantQuantity) * 100) : 0;
|
||||
const metadata = parseProductName(variant.name);
|
||||
|
||||
return (
|
||||
<div key={variant.id} className="rounded-xl border border-dark-border bg-dark-input/45 p-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-bold text-dark-text">{variant.name}</div>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPlanningModalOpen && (
|
||||
<SkuPlanningModal
|
||||
product={productInfo}
|
||||
override={planningSettings.productOverrides[productInfo.id]}
|
||||
isSaving={isSavingPlanning}
|
||||
onClose={() => setIsPlanningModalOpen(false)}
|
||||
onSave={(override) => saveProductOverride(productInfo.id, override)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
623
src/pages/ProductGroupDetails.tsx
Normal file
623
src/pages/ProductGroupDetails.tsx
Normal file
@@ -0,0 +1,623 @@
|
||||
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;
|
||||
684
src/pages/ProductionOrders.tsx
Normal file
684
src/pages/ProductionOrders.tsx
Normal file
@@ -0,0 +1,684 @@
|
||||
import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary, updateProductionOrderStatus } from '../dataService';
|
||||
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types';
|
||||
|
||||
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled';
|
||||
|
||||
const emptySummary: ProductionOrderSummary = {
|
||||
orders: [],
|
||||
counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 }
|
||||
};
|
||||
|
||||
const statusTabs: Array<{ key: ProductionOrderStatusTab; label: string; dotClass: string }> = [
|
||||
{ key: 'all', label: 'Todas', dotClass: 'bg-dark-muted' },
|
||||
{ key: 'open', label: 'Em aberto', dotClass: 'bg-amber-400' },
|
||||
{ key: 'in_progress', label: 'Em andamento', dotClass: 'bg-sky-400' },
|
||||
{ key: 'finished', label: 'Finalizada', dotClass: 'bg-emerald-400' },
|
||||
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
|
||||
];
|
||||
|
||||
const editableStatusOptions: Array<{ value: ProductionOrderStatusTab; label: string }> = statusTabs
|
||||
.filter(tab => tab.key !== 'all')
|
||||
.map(tab => ({ value: tab.key, label: tab.label }));
|
||||
|
||||
const statusStyles: Record<string, { label: string; className: string; dotClass: string }> = {
|
||||
open: {
|
||||
label: 'Em aberto',
|
||||
className: 'border-amber-400/35 bg-amber-400/10 text-amber-300',
|
||||
dotClass: 'bg-amber-400'
|
||||
},
|
||||
in_progress: {
|
||||
label: 'Em andamento',
|
||||
className: 'border-sky-400/35 bg-sky-400/10 text-sky-300',
|
||||
dotClass: 'bg-sky-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 formatOptionalQuantity = (value: number | null) => (
|
||||
value === null ? '-' : formatQuantity(value)
|
||||
);
|
||||
|
||||
const hasProductionOrderDetails = (order: ProductionOrderItem) => (
|
||||
order.components.length > 0 ||
|
||||
order.steps.length > 0 ||
|
||||
Boolean(order.notes || order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg)
|
||||
);
|
||||
|
||||
const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) => (
|
||||
statusStyles[String(status)] || {
|
||||
label: fallbackLabel || 'Em aberto',
|
||||
className: 'border-dark-border bg-dark-input text-dark-muted',
|
||||
dotClass: 'bg-dark-muted'
|
||||
}
|
||||
);
|
||||
|
||||
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 [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all');
|
||||
const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary);
|
||||
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSupplyBusy, setIsSupplyBusy] = useState(false);
|
||||
const [supplyMessage, setSupplyMessage] = useState('');
|
||||
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
const [exitForm, setExitForm] = useState({
|
||||
orderId: '',
|
||||
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] = await Promise.all([
|
||||
fetchProductionOrders(dateRange, { search: searchTerm }),
|
||||
fetchSupplySummary()
|
||||
]);
|
||||
if (isMounted) {
|
||||
setSummary(nextSummary);
|
||||
setSupplyLots(nextSupplySummary.lots);
|
||||
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.in_progress || 0;
|
||||
const finishedCount = summary.counts.finished || 0;
|
||||
const totalQuantity = summary.orders.reduce((total, order) => total + order.quantity, 0);
|
||||
|
||||
const handleManualRefresh = () => {
|
||||
void loadProductionOrders({ force: true });
|
||||
};
|
||||
|
||||
const handleStatusChange = async (order: ProductionOrderItem, status: ProductionOrderStatusTab) => {
|
||||
if (status === 'all' || status === order.status) return;
|
||||
|
||||
setBusyStatusOrderId(order.id);
|
||||
setStatusMessage('');
|
||||
try {
|
||||
await updateProductionOrderStatus(order.id, status);
|
||||
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
|
||||
setSummary(nextSummary);
|
||||
setStatusMessage(`OP ${order.number || `#${order.id}`} atualizada para ${statusStyles[status]?.label || status}.`);
|
||||
} catch (error) {
|
||||
setStatusMessage(error instanceof Error ? error.message : 'Não foi possível atualizar o status da OP.');
|
||||
} finally {
|
||||
setBusyStatusOrderId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSupplyLots = async () => {
|
||||
const supplySummary = await fetchSupplySummary();
|
||||
setSupplyLots(supplySummary.lots);
|
||||
};
|
||||
|
||||
const selectedOrder = summary.orders.find(order => `${order.id}` === exitForm.orderId);
|
||||
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({ orderId: '', 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 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`);
|
||||
};
|
||||
|
||||
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 as ordens locais geradas pelo plano de corte.</p>
|
||||
</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(totalQuantity)} un. no período</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>
|
||||
|
||||
<form onSubmit={handleProductionExit} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-dark-text">Baixa de material da OP</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Consome um lote do estoque e registra a saída nas movimentações.</p>
|
||||
</div>
|
||||
{selectedLot && (
|
||||
<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">
|
||||
Saldo lote #{selectedLot.id}: {formatQuantity(selectedLot.quantity)} {selectedLot.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 xl:grid-cols-[1.4fr_1.4fr_120px_1fr_auto]">
|
||||
<label className="text-xs font-bold text-dark-muted">
|
||||
Ordem de produção
|
||||
<select
|
||||
value={exitForm.orderId}
|
||||
onChange={(event) => setExitForm(current => ({ ...current, orderId: event.target.value }))}
|
||||
className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{summary.orders.filter(order => order.status !== 'finished' && order.status !== 'canceled').map(order => (
|
||||
<option key={order.id} value={order.id}>
|
||||
{order.number || `#${order.id}`} · {order.productDescription}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<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 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{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 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"
|
||||
placeholder="kg"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs font-bold text-dark-muted">
|
||||
Motivo
|
||||
<input
|
||||
value={exitForm.reason}
|
||||
onChange={(event) => setExitForm(current => ({ ...current, reason: event.target.value }))}
|
||||
className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary"
|
||||
placeholder="ex: corte do pedido"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSupplyBusy || !supplyLots.length}
|
||||
className="mt-5 inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<PackageCheck className="h-4 w-4 text-brand-primary" />
|
||||
Baixar
|
||||
</button>
|
||||
</div>
|
||||
{supplyMessage && (
|
||||
<div className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">
|
||||
{supplyMessage}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||
<div className="border-b border-dark-border p-5">
|
||||
{statusMessage && (
|
||||
<div className="mb-4 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="relative w-full xl:max-w-xl">
|
||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
|
||||
<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-[1180px] 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">Pedidos</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Data</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Data Prevista</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">SKU / 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">Marcadores</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Integrações</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{paginatedOrders.map((order: ProductionOrderItem) => {
|
||||
const statusStyle = getStatusStyle(order.status, order.statusLabel);
|
||||
const showDetails = hasProductionOrderDetails(order);
|
||||
return (
|
||||
<Fragment key={order.id}>
|
||||
<tr className="transition-colors hover:bg-dark-input/50">
|
||||
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
|
||||
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{order.orderReference || '-'}</td>
|
||||
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
{formatDate(order.issueDate)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{formatDate(order.expectedDate)}</td>
|
||||
<td className="px-6 py-3">
|
||||
<div className="font-bold text-dark-text">{order.productDescription}</div>
|
||||
<div className="mt-1 font-mono text-[10px] font-semibold text-dark-muted">{order.productSku || 'Sem SKU'}</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">
|
||||
{order.markers.length ? (
|
||||
<div className="flex max-w-52 flex-wrap gap-1.5">
|
||||
{order.markers.map(marker => (
|
||||
<span
|
||||
key={`${order.id}-${marker.label}`}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: marker.color || 'var(--color-dark-muted)' }} />
|
||||
{marker.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-dark-muted">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<span className="inline-flex items-center gap-2 text-xs font-semibold text-dark-muted">
|
||||
<span className="h-2 w-2 rounded-full bg-sky-400" />
|
||||
{order.integrationStatus || 'Tiny'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<label className="sr-only" htmlFor={`production-order-status-${order.id}`}>Status da OP</label>
|
||||
<select
|
||||
id={`production-order-status-${order.id}`}
|
||||
value={order.status}
|
||||
disabled={busyStatusOrderId === order.id}
|
||||
onChange={(event) => void handleStatusChange(order, event.target.value as ProductionOrderStatusTab)}
|
||||
className={`h-8 rounded-lg border bg-dark-input px-2 text-[10px] font-bold uppercase tracking-wide outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 ${statusStyle.className}`}
|
||||
>
|
||||
{editableStatusOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{showDetails && (
|
||||
<tr className="bg-dark-input/20">
|
||||
<td colSpan={9} className="px-6 pb-5 pt-1">
|
||||
<div className="grid grid-cols-1 gap-4 rounded-xl border border-dark-border bg-dark-card/80 p-4 xl:grid-cols-[1.4fr_1fr_1fr]">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
|
||||
{order.components.length ? (
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-dark-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-dark-header text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-bold">Produto</th>
|
||||
<th className="px-3 py-2 text-left font-bold">SKU</th>
|
||||
<th className="px-3 py-2 text-right font-bold">Qtd.</th>
|
||||
<th className="px-3 py-2 text-right font-bold">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{order.components.map(component => (
|
||||
<tr key={component.id}>
|
||||
<td className="px-3 py-2 font-semibold text-dark-text">{component.componentName}</td>
|
||||
<td className="px-3 py-2 font-mono text-dark-muted">{component.componentSku || '-'}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-dark-muted">{formatQuantity(component.quantityPerUnit)} {component.unit}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-dark-text">{formatQuantity(component.totalQuantity)} {component.unit}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs font-semibold text-dark-muted">Sem composição sincronizada.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Etapas</h3>
|
||||
{order.steps.length ? (
|
||||
<div className="mt-3 divide-y divide-dark-border rounded-lg border border-dark-border">
|
||||
{order.steps.map(step => (
|
||||
<div key={step.id} className="grid grid-cols-[32px_1fr_auto] items-center gap-3 px-3 py-2 text-xs">
|
||||
<span className="font-mono font-bold text-dark-muted">{step.stepNumber ?? '-'}</span>
|
||||
<div>
|
||||
<p className="font-bold text-dark-text">{step.name}</p>
|
||||
<p className="mt-0.5 font-semibold text-dark-muted">{formatDate(step.startDate)} - {formatDate(step.endDate)}</p>
|
||||
</div>
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: step.color || 'var(--color-brand-primary)' }} title={step.status || 'Sem status'} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs font-semibold text-dark-muted">Sem etapas sincronizadas.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Observações</h3>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div><dt className="font-bold text-dark-muted">Fornecedor</dt><dd className="mt-0.5 font-semibold text-dark-text">{order.supplier || '-'}</dd></div>
|
||||
<div><dt className="font-bold text-dark-muted">Lote</dt><dd className="mt-0.5 font-semibold text-dark-text">{order.lotCode || '-'}</dd></div>
|
||||
<div><dt className="font-bold text-dark-muted">Rolos</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.rollQuantity)}</dd></div>
|
||||
<div><dt className="font-bold text-dark-muted">Malha kg</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.fabricKg)}</dd></div>
|
||||
<div><dt className="font-bold text-dark-muted">Ribana kg</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.ribKg)}</dd></div>
|
||||
<div><dt className="font-bold text-dark-muted">Rendimento</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.yieldPiecesPerKg)}</dd></div>
|
||||
</dl>
|
||||
{order.notes && <p className="mt-3 whitespace-pre-wrap rounded-lg border border-dark-border bg-dark-input p-3 text-xs font-semibold text-dark-muted">{order.notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</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 de produção encontrada.</p>
|
||||
<p className="mt-1 max-w-md text-sm font-medium text-dark-muted">
|
||||
Gere ordens pelo Plano de Corte para acompanhar status, produto, quantidade e baixa de material dentro do Graphs.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionOrders;
|
||||
File diff suppressed because it is too large
Load Diff
819
src/pages/Registrations.tsx
Normal file
819
src/pages/Registrations.tsx
Normal file
@@ -0,0 +1,819 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { ClipboardList, Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
deleteCatalogCategory,
|
||||
deleteCatalogProduct,
|
||||
deleteConsumptionReference,
|
||||
fetchCatalogSummary,
|
||||
saveCatalogCategory,
|
||||
saveCatalogProduct,
|
||||
saveConsumptionReference
|
||||
} from '../dataService';
|
||||
import type { CatalogCategory, CatalogProduct, CatalogProductType, CatalogSummary, ConsumptionReference } from '../types';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
|
||||
type RegistrationTab = 'products' | 'categories' | 'references';
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
const emptyCatalog: CatalogSummary = {
|
||||
categories: [],
|
||||
products: [],
|
||||
consumptionReferences: []
|
||||
};
|
||||
|
||||
const sizeOptions = ['2', '4', '6', '8', '10', '12', '14', '16', 'PP', 'P', 'M', 'G', 'GG', 'XG', 'G1', 'G2', 'G3', 'G4', 'G5'];
|
||||
const defaultSizeAreas: Record<string, string> = {
|
||||
P: '0.78',
|
||||
M: '0.85',
|
||||
G: '0.92',
|
||||
GG: '1.00',
|
||||
XG: '1.08',
|
||||
G1: '1.08',
|
||||
G2: '1.16',
|
||||
G3: '1.24',
|
||||
G4: '1.32',
|
||||
G5: '1.40'
|
||||
};
|
||||
|
||||
const defaultProductForm = {
|
||||
type: 'finished_product' as CatalogProductType,
|
||||
sku: '',
|
||||
name: '',
|
||||
categoryId: '',
|
||||
composition: '',
|
||||
notes: '',
|
||||
gramature: '',
|
||||
materialYield: '',
|
||||
widthCm: '',
|
||||
color: '',
|
||||
subcategory: 'malha',
|
||||
sizes: ['P', 'M', 'G', 'GG'] as string[]
|
||||
};
|
||||
|
||||
const rawMaterialSubcategories = [
|
||||
{ value: 'fio', label: 'Fio' },
|
||||
{ value: 'malha', label: 'Malha' },
|
||||
{ value: 'ribana', label: 'Ribana' },
|
||||
{ value: 'malha_fria', label: 'Malha Fria' },
|
||||
{ value: 'meia_malha', label: 'Meia Malha 100% Algodao' },
|
||||
{ value: 'moletom', label: 'Moletom' },
|
||||
{ value: 'dry_fit', label: 'Dry Fit' },
|
||||
{ value: 'piquet', label: 'Piquet / Polo' },
|
||||
{ value: 'pima', label: 'Pima' },
|
||||
{ value: 'poliamida', label: 'Poliamida' },
|
||||
{ value: 'outra', label: 'Outra' }
|
||||
];
|
||||
|
||||
const listPanelClassName = 'rounded-2xl border border-dark-border bg-dark-card shadow-sm';
|
||||
const listHeaderClassName = 'flex min-h-[73px] flex-col gap-3 border-b border-dark-border p-4 md:flex-row md:items-center md:justify-between';
|
||||
const formPanelClassName = 'rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm';
|
||||
const emptyStateClassName = 'flex min-h-[280px] items-center justify-center px-4 py-10 text-center text-sm font-semibold text-dark-muted';
|
||||
const labelClassName = 'text-xs font-bold text-dark-muted';
|
||||
const inputClassName = 'mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary';
|
||||
|
||||
const formatNumber = (value: number | null | undefined, maximumFractionDigits = 2) => (
|
||||
value === null || value === undefined
|
||||
? '-'
|
||||
: new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
|
||||
);
|
||||
|
||||
const numericMapFromStrings = (values: Record<string, string>) => (
|
||||
Object.entries(values).reduce<Record<string, number>>((normalized, [key, value]) => {
|
||||
const number = Number(value.replace(',', '.'));
|
||||
if (Number.isFinite(number) && number > 0) normalized[key] = number;
|
||||
return normalized;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const getAverageYield = (reference: ConsumptionReference) => {
|
||||
const yields = Object.values(reference.sizeYields || {});
|
||||
if (yields.length) return yields.reduce((total, value) => total + value, 0) / yields.length;
|
||||
return reference.generalYield;
|
||||
};
|
||||
|
||||
const getReferenceSourceLabel = (reference: ConsumptionReference) => (
|
||||
reference.source === 'tiny_op' ? 'Tiny OP' : 'Manual'
|
||||
);
|
||||
|
||||
const formatConsumptionPerPiece = (reference: ConsumptionReference) => {
|
||||
if (!reference.consumptionQuantity) return '';
|
||||
const unit = reference.consumptionUnit || 'un.';
|
||||
return `${formatNumber(reference.consumptionQuantity, 4)} ${unit}/peça`;
|
||||
};
|
||||
|
||||
const Registrations = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [catalog, setCatalog] = useState<CatalogSummary>(emptyCatalog);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<RegistrationTab>('products');
|
||||
const [productFilter, setProductFilter] = useState<'all' | CatalogProductType>('all');
|
||||
const [status, setStatus] = useState<SaveStatus>('idle');
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const appliedSkuPrefillRef = useRef('');
|
||||
|
||||
const [categoryForm, setCategoryForm] = useState({ name: '', description: '' });
|
||||
const [productForm, setProductForm] = useState(defaultProductForm);
|
||||
const [referenceForm, setReferenceForm] = useState({
|
||||
productId: '',
|
||||
materialProductId: '',
|
||||
color: '',
|
||||
generalYield: '',
|
||||
gramature: '',
|
||||
efficiencyPercent: '85',
|
||||
ribGPerPiece: '',
|
||||
materialCostPerKg: ''
|
||||
});
|
||||
const [sizeAreas, setSizeAreas] = useState<Record<string, string>>(defaultSizeAreas);
|
||||
const [sizeYields, setSizeYields] = useState<Record<string, string>>({});
|
||||
|
||||
const loadCatalog = async () => {
|
||||
setIsLoading(true);
|
||||
const summary = await fetchCatalogSummary();
|
||||
setCatalog(summary);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
fetchCatalogSummary().then(summary => {
|
||||
if (!isMounted) return;
|
||||
setCatalog(summary);
|
||||
setIsLoading(false);
|
||||
}).catch(error => {
|
||||
console.error('Initial catalog load failed', error);
|
||||
if (!isMounted) return;
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const requestedTab = searchParams.get('tab');
|
||||
const sku = (searchParams.get('sku') || '').trim();
|
||||
if (!sku && !(requestedTab === 'products' || requestedTab === 'categories' || requestedTab === 'references')) return;
|
||||
|
||||
const prefillKey = `${requestedTab || ''}|${sku}|${searchParams.get('name') || ''}|${searchParams.get('color') || ''}|${catalog.products.length}`;
|
||||
if (appliedSkuPrefillRef.current === prefillKey) return;
|
||||
appliedSkuPrefillRef.current = prefillKey;
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (requestedTab === 'products' || requestedTab === 'categories' || requestedTab === 'references') {
|
||||
setActiveTab(requestedTab);
|
||||
}
|
||||
|
||||
if (!sku) return;
|
||||
|
||||
const existingProduct = catalog.products.find(product => product.sku.toLowerCase() === sku.toLowerCase());
|
||||
setProductFilter('all');
|
||||
setStatus('idle');
|
||||
|
||||
if (requestedTab === 'references' && existingProduct) {
|
||||
setActiveTab('references');
|
||||
setReferenceForm(current => ({
|
||||
...current,
|
||||
productId: String(existingProduct.id),
|
||||
color: searchParams.get('color') || existingProduct.color || current.color
|
||||
}));
|
||||
setFeedback(`Criando referência de consumo para SKU ${existingProduct.sku}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveTab('products');
|
||||
|
||||
if (existingProduct) {
|
||||
setProductForm({
|
||||
type: existingProduct.type,
|
||||
sku: existingProduct.sku,
|
||||
name: existingProduct.name,
|
||||
categoryId: existingProduct.categoryId ? String(existingProduct.categoryId) : '',
|
||||
composition: existingProduct.composition || '',
|
||||
notes: existingProduct.notes || '',
|
||||
gramature: existingProduct.gramature ? String(existingProduct.gramature) : '',
|
||||
materialYield: existingProduct.materialYield ? String(existingProduct.materialYield) : '',
|
||||
widthCm: existingProduct.widthCm ? String(existingProduct.widthCm) : '',
|
||||
color: existingProduct.color || searchParams.get('color') || '',
|
||||
subcategory: existingProduct.subcategory || 'malha',
|
||||
sizes: existingProduct.sizes?.length ? existingProduct.sizes : defaultProductForm.sizes
|
||||
});
|
||||
setFeedback(`Editando SKU ${existingProduct.sku}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedSize = (searchParams.get('size') || '').trim().toUpperCase();
|
||||
setProductForm({
|
||||
...defaultProductForm,
|
||||
sku,
|
||||
name: searchParams.get('name') || '',
|
||||
color: searchParams.get('color') || '',
|
||||
sizes: requestedSize ? [requestedSize] : defaultProductForm.sizes
|
||||
});
|
||||
setFeedback(
|
||||
requestedTab === 'references'
|
||||
? `Cadastre o SKU ${sku} antes de criar a referência de consumo.`
|
||||
: `Novo cadastro para SKU ${sku}.`
|
||||
);
|
||||
});
|
||||
}, [catalog.products, searchParams]);
|
||||
|
||||
const finishedProducts = useMemo(
|
||||
() => catalog.products.filter(product => product.type === 'finished_product'),
|
||||
[catalog.products]
|
||||
);
|
||||
|
||||
const rawMaterials = useMemo(
|
||||
() => catalog.products.filter(product => product.type === 'raw_material'),
|
||||
[catalog.products]
|
||||
);
|
||||
|
||||
const selectedReferenceProduct = useMemo(
|
||||
() => catalog.products.find(product => String(product.id) === referenceForm.productId),
|
||||
[catalog.products, referenceForm.productId]
|
||||
);
|
||||
|
||||
const referenceSizes = selectedReferenceProduct?.sizes?.length ? selectedReferenceProduct.sizes : ['P', 'M', 'G', 'GG'];
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
if (productFilter === 'all') return catalog.products;
|
||||
return catalog.products.filter(product => product.type === productFilter);
|
||||
}, [catalog.products, productFilter]);
|
||||
|
||||
const runAction = async (action: () => Promise<void>, successMessage: string) => {
|
||||
setStatus('saving');
|
||||
setFeedback('');
|
||||
try {
|
||||
await action();
|
||||
await loadCatalog();
|
||||
setStatus('saved');
|
||||
setFeedback(successMessage);
|
||||
} catch (error) {
|
||||
setStatus('error');
|
||||
setFeedback(error instanceof Error ? error.message : 'Nao foi possivel salvar.');
|
||||
}
|
||||
};
|
||||
|
||||
const saveCategory = () => runAction(async () => {
|
||||
await saveCatalogCategory(categoryForm);
|
||||
setCategoryForm({ name: '', description: '' });
|
||||
}, 'Categoria salva.');
|
||||
|
||||
const saveProduct = () => runAction(async () => {
|
||||
await saveCatalogProduct({
|
||||
...productForm,
|
||||
categoryId: productForm.categoryId ? Number(productForm.categoryId) : null,
|
||||
sizes: productForm.type === 'finished_product' ? productForm.sizes : []
|
||||
});
|
||||
setProductForm(current => ({
|
||||
...current,
|
||||
sku: '',
|
||||
name: '',
|
||||
composition: '',
|
||||
notes: '',
|
||||
gramature: '',
|
||||
materialYield: '',
|
||||
widthCm: '',
|
||||
color: ''
|
||||
}));
|
||||
}, 'Produto salvo.');
|
||||
|
||||
const calculateSizeYields = () => {
|
||||
const gramature = Number(referenceForm.gramature.replace(',', '.'));
|
||||
const efficiency = Number(referenceForm.efficiencyPercent.replace(',', '.')) || 85;
|
||||
const rib = Number(referenceForm.ribGPerPiece.replace(',', '.')) || 0;
|
||||
|
||||
if (!Number.isFinite(gramature) || gramature <= 0) {
|
||||
setStatus('error');
|
||||
setFeedback('Informe a gramatura para calcular o rendimento.');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextYields = referenceSizes.reduce<Record<string, string>>((values, size) => {
|
||||
const area = Number((sizeAreas[size] || '').replace(',', '.'));
|
||||
if (!Number.isFinite(area) || area <= 0) return values;
|
||||
const fabricGrams = gramature * area / (efficiency / 100);
|
||||
const pieceGrams = fabricGrams + rib;
|
||||
const yieldValue = pieceGrams > 0 ? 1000 / pieceGrams : 0;
|
||||
values[size] = yieldValue.toFixed(2);
|
||||
return values;
|
||||
}, {});
|
||||
|
||||
setSizeYields(nextYields);
|
||||
setStatus('idle');
|
||||
setFeedback(`${Object.keys(nextYields).length} tamanhos calculados.`);
|
||||
};
|
||||
|
||||
const saveReference = () => runAction(async () => {
|
||||
await saveConsumptionReference({
|
||||
...referenceForm,
|
||||
productId: Number(referenceForm.productId),
|
||||
materialProductId: referenceForm.materialProductId ? Number(referenceForm.materialProductId) : null,
|
||||
sizeAreas: numericMapFromStrings(sizeAreas),
|
||||
sizeYields: numericMapFromStrings(sizeYields)
|
||||
});
|
||||
setReferenceForm(current => ({
|
||||
...current,
|
||||
productId: '',
|
||||
materialProductId: '',
|
||||
color: '',
|
||||
generalYield: ''
|
||||
}));
|
||||
setSizeYields({});
|
||||
}, 'Referencia salva.');
|
||||
|
||||
const removeCategory = (category: CatalogCategory) => runAction(
|
||||
() => deleteCatalogCategory(category.id),
|
||||
`Categoria ${category.name} excluida.`
|
||||
);
|
||||
|
||||
const removeProduct = (product: CatalogProduct) => runAction(
|
||||
() => deleteCatalogProduct(product.id),
|
||||
`Produto ${product.sku} excluido.`
|
||||
);
|
||||
|
||||
const removeReference = (reference: ConsumptionReference) => runAction(
|
||||
() => deleteConsumptionReference(reference.id),
|
||||
`Referencia ${reference.productSku} excluida.`
|
||||
);
|
||||
|
||||
const toggleProductSize = (size: string) => {
|
||||
setProductForm(current => ({
|
||||
...current,
|
||||
sizes: current.sizes.includes(size)
|
||||
? current.sizes.filter(item => item !== size)
|
||||
: [...current.sizes, size]
|
||||
}));
|
||||
};
|
||||
|
||||
const tabItems: Array<{ key: RegistrationTab; label: string; icon: typeof Package; count: number }> = [
|
||||
{ key: 'products', label: 'Produtos', icon: Package, count: catalog.products.length },
|
||||
{ key: 'categories', label: 'Categorias', icon: Tags, count: catalog.categories.length },
|
||||
{ key: 'references', label: 'Referência de Consumo', icon: Ruler, count: catalog.consumptionReferences.length }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 2xl:flex-row 2xl:items-start 2xl:justify-between">
|
||||
<div>
|
||||
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Cadastros</h1>
|
||||
<p className="font-medium text-zinc-500 dark:text-dark-muted">
|
||||
Fonte de verdade para produtos, matérias-primas e referências de consumo.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
to="/planning-issues"
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
|
||||
>
|
||||
<ClipboardList className="h-4 w-4 text-brand-primary" />
|
||||
Dados Pendentes
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadCatalog()}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 text-brand-primary" />
|
||||
Atualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{tabItems.map(item => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(item.key)}
|
||||
className={`flex items-center justify-between rounded-2xl border p-4 text-left transition-colors cursor-pointer ${isActive ? 'border-brand-primary/40 bg-brand-primary/10 text-brand-primary' : 'border-dark-border bg-dark-card text-dark-text hover:border-brand-primary/40'}`}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="rounded-xl border border-dark-border bg-dark-input p-2">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="font-bold">{item.label}</span>
|
||||
</span>
|
||||
<span className="rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">
|
||||
{item.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{feedback && (
|
||||
<div className={`rounded-2xl border px-4 py-3 text-sm font-bold ${status === 'error' ? 'border-red-400/30 bg-red-400/10 text-red-300' : 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'}`}>
|
||||
{feedback}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 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>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'products' && (
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_420px]">
|
||||
<div className={listPanelClassName}>
|
||||
<div className={listHeaderClassName}>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-dark-text">Produtos cadastrados</h2>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Produtos acabados e materias-primas do corte.</p>
|
||||
</div>
|
||||
<select
|
||||
value={productFilter}
|
||||
onChange={(event) => setProductFilter(event.target.value as 'all' | CatalogProductType)}
|
||||
className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="finished_product">Produtos acabados</option>
|
||||
<option value="raw_material">Materias-primas</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="divide-y divide-dark-border">
|
||||
{filteredProducts.map(product => (
|
||||
<div key={product.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<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">{product.sku}</span>
|
||||
<span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">
|
||||
{product.type === 'finished_product' ? 'Produto acabado' : 'Materia-prima'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-1 truncate text-sm font-bold text-dark-text">{product.name}</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{[product.categoryName, product.composition, product.color ? formatColorLabel(product.color) : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ') || 'Sem detalhes'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void removeProduct(product)}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer"
|
||||
title="Excluir produto"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!filteredProducts.length && (
|
||||
<div className={emptyStateClassName}>Nenhum produto cadastrado.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={formPanelClassName}>
|
||||
<h2 className="text-sm font-bold text-dark-text">Cadastrar produto / materia-prima</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className={labelClassName}>
|
||||
Tipo
|
||||
<select
|
||||
value={productForm.type}
|
||||
onChange={(event) => setProductForm(current => ({ ...current, type: event.target.value as CatalogProductType }))}
|
||||
className={inputClassName}
|
||||
>
|
||||
<option value="finished_product">Produto acabado</option>
|
||||
<option value="raw_material">Materia-prima</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={labelClassName}>
|
||||
SKU
|
||||
<input
|
||||
value={productForm.sku}
|
||||
onChange={(event) => setProductForm(current => ({ ...current, sku: event.target.value }))}
|
||||
placeholder="ex: BLCS"
|
||||
className={`${inputClassName} font-mono`}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className={`block ${labelClassName}`}>
|
||||
Nome
|
||||
<input
|
||||
value={productForm.name}
|
||||
onChange={(event) => setProductForm(current => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Nome do produto"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className={labelClassName}>
|
||||
Categoria
|
||||
<select
|
||||
value={productForm.categoryId}
|
||||
onChange={(event) => setProductForm(current => ({ ...current, categoryId: event.target.value }))}
|
||||
className={inputClassName}
|
||||
>
|
||||
<option value="">Sem categoria</option>
|
||||
{catalog.categories.map(category => <option key={category.id} value={category.id}>{category.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className={labelClassName}>
|
||||
Composicao
|
||||
<input
|
||||
value={productForm.composition}
|
||||
onChange={(event) => setProductForm(current => ({ ...current, composition: event.target.value }))}
|
||||
placeholder="ex: 100% Algodao"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{productForm.type === 'raw_material' ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<label className={labelClassName}>
|
||||
Gramatura
|
||||
<input inputMode="decimal" value={productForm.gramature} onChange={(event) => setProductForm(current => ({ ...current, gramature: event.target.value }))} className={inputClassName} />
|
||||
</label>
|
||||
<label className={labelClassName}>
|
||||
Rendimento m/kg
|
||||
<input inputMode="decimal" value={productForm.materialYield} onChange={(event) => setProductForm(current => ({ ...current, materialYield: event.target.value }))} className={inputClassName} />
|
||||
</label>
|
||||
<label className={labelClassName}>
|
||||
Largura cm
|
||||
<input inputMode="decimal" value={productForm.widthCm} onChange={(event) => setProductForm(current => ({ ...current, widthCm: event.target.value }))} className={inputClassName} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className={labelClassName}>
|
||||
Cor
|
||||
<input value={productForm.color} onChange={(event) => setProductForm(current => ({ ...current, color: event.target.value }))} placeholder="ex: Preto" className={inputClassName} />
|
||||
</label>
|
||||
<label className={labelClassName}>
|
||||
Subcategoria
|
||||
<select value={productForm.subcategory} onChange={(event) => setProductForm(current => ({ ...current, subcategory: event.target.value }))} className={inputClassName}>
|
||||
{rawMaterialSubcategories.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className={`block ${labelClassName}`}>
|
||||
Cor
|
||||
<input
|
||||
value={productForm.color}
|
||||
onChange={(event) => setProductForm(current => ({ ...current, color: event.target.value }))}
|
||||
placeholder="ex: Preto"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold text-dark-muted">Tamanhos disponiveis</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sizeOptions.map(size => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
onClick={() => toggleProductSize(size)}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-bold transition-colors cursor-pointer ${productForm.sizes.includes(size) ? 'border-brand-primary bg-brand-primary/15 text-brand-primary' : 'border-dark-border bg-dark-input text-dark-muted hover:text-dark-text'}`}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className={`block ${labelClassName}`}>
|
||||
Observacoes
|
||||
<input
|
||||
value={productForm.notes}
|
||||
onChange={(event) => setProductForm(current => ({ ...current, notes: event.target.value }))}
|
||||
placeholder="Opcional"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveProduct()}
|
||||
disabled={status === 'saving'}
|
||||
className="inline-flex h-11 w-full 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-50 cursor-pointer"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Salvar produto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'categories' && (
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_420px]">
|
||||
<div className={listPanelClassName}>
|
||||
<div className={listHeaderClassName}>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-dark-text">Categorias cadastradas</h2>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Classificacao usada no cadastro de produtos.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-dark-border">
|
||||
{catalog.categories.map(category => (
|
||||
<div key={category.id} className="flex items-center justify-between gap-3 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-dark-text">{category.name}</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{category.description || 'Sem descricao'}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void removeCategory(category)} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!catalog.categories.length && <div className={emptyStateClassName}>Nenhuma categoria cadastrada.</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={formPanelClassName}>
|
||||
<h2 className="text-sm font-bold text-dark-text">Nova categoria</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className={`block ${labelClassName}`}>
|
||||
Nome
|
||||
<input value={categoryForm.name} onChange={(event) => setCategoryForm(current => ({ ...current, name: event.target.value }))} placeholder="ex: Camiseta Regular" className={inputClassName} />
|
||||
</label>
|
||||
<label className={`block ${labelClassName}`}>
|
||||
Descricao
|
||||
<input value={categoryForm.description} onChange={(event) => setCategoryForm(current => ({ ...current, description: event.target.value }))} placeholder="Opcional" className={inputClassName} />
|
||||
</label>
|
||||
<button type="button" onClick={() => void saveCategory()} disabled={status === 'saving'} className="inline-flex h-11 w-full 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-50 cursor-pointer">
|
||||
<Save className="h-4 w-4" />
|
||||
Salvar categoria
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'references' && (
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_420px]">
|
||||
<div className={listPanelClassName}>
|
||||
<div className={listHeaderClassName}>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-dark-text">Referencias cadastradas</h2>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Rendimento pç/kg por produto, malha, cor e tamanho.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-dark-border bg-dark-input px-3 py-1 text-xs font-bold text-dark-muted">
|
||||
{catalog.consumptionReferences.length} referencias
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-dark-border">
|
||||
{catalog.consumptionReferences.map(reference => (
|
||||
<div key={reference.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
|
||||
<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">{reference.productSku}</span>
|
||||
<span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">
|
||||
{Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
|
||||
</span>
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${
|
||||
reference.source === 'tiny_op'
|
||||
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
|
||||
: 'border-dark-border bg-dark-input text-dark-muted'
|
||||
}`}>
|
||||
{getReferenceSourceLabel(reference)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-1 truncate text-sm font-bold text-dark-text">{reference.productName}</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{[reference.materialName, reference.color ? formatColorLabel(reference.color) : 'todas as cores'].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{!!Object.keys(reference.sizeYields || {}).length && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{Object.entries(reference.sizeYields).map(([size, value]) => (
|
||||
<span key={size} className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">
|
||||
{size}: {formatNumber(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-3">
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-emerald-300">
|
||||
{formatConsumptionPerPiece(reference) || `${formatNumber(getAverageYield(reference), 3)} pç/kg`}
|
||||
</div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">
|
||||
{reference.consumptionQuantity
|
||||
? 'consumo'
|
||||
: Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
|
||||
</div>
|
||||
{reference.consumptionQuantity && getAverageYield(reference) ? (
|
||||
<div className="mt-1 text-[11px] font-semibold text-dark-muted">
|
||||
{formatNumber(getAverageYield(reference), 3)} pç/kg
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" onClick={() => void removeReference(reference)} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!catalog.consumptionReferences.length && (
|
||||
<div className={emptyStateClassName}>
|
||||
Nenhuma referencia cadastrada.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={formPanelClassName}>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-dark-text">Nova referencia de consumo</h2>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
Defina pç/kg por produto, malha e cor para o calculo do corte.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className={labelClassName}>
|
||||
Produto
|
||||
<select value={referenceForm.productId} onChange={(event) => setReferenceForm(current => ({ ...current, productId: event.target.value }))} className={inputClassName}>
|
||||
<option value="">Selecione...</option>
|
||||
{finishedProducts.map(product => <option key={product.id} value={product.id}>{product.name} ({product.sku})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className={labelClassName}>
|
||||
Malha
|
||||
<select value={referenceForm.materialProductId} onChange={(event) => setReferenceForm(current => ({ ...current, materialProductId: event.target.value }))} className={inputClassName}>
|
||||
<option value="">Qualquer</option>
|
||||
{rawMaterials.map(product => <option key={product.id} value={product.id}>{product.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className={labelClassName}>
|
||||
Cor
|
||||
<input value={referenceForm.color} onChange={(event) => setReferenceForm(current => ({ ...current, color: event.target.value }))} placeholder="Todas as cores" className={inputClassName} />
|
||||
</label>
|
||||
<label className={labelClassName}>
|
||||
Rendimento geral
|
||||
<input inputMode="decimal" value={referenceForm.generalYield} onChange={(event) => setReferenceForm(current => ({ ...current, generalYield: event.target.value }))} placeholder="ex: 5,36" className={inputClassName} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-3">
|
||||
<div className="mb-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-text">Calculo por gramatura</h3>
|
||||
<p className="mt-1 text-[11px] font-semibold text-dark-muted">Opcional. Preenche o pç/kg dos tamanhos abaixo.</p>
|
||||
</div>
|
||||
<button type="button" onClick={calculateSizeYields} className="inline-flex h-9 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 cursor-pointer">
|
||||
<Ruler className="h-3.5 w-3.5 text-brand-primary" />
|
||||
Calcular
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className={labelClassName}>Gramatura<input inputMode="decimal" value={referenceForm.gramature} onChange={(event) => setReferenceForm(current => ({ ...current, gramature: event.target.value }))} placeholder="ex: 180" className={inputClassName} /></label>
|
||||
<label className={labelClassName}>Aproveitamento %<input inputMode="decimal" value={referenceForm.efficiencyPercent} onChange={(event) => setReferenceForm(current => ({ ...current, efficiencyPercent: event.target.value }))} className={inputClassName} /></label>
|
||||
<label className={labelClassName}>Ribana g/peça<input inputMode="decimal" value={referenceForm.ribGPerPiece} onChange={(event) => setReferenceForm(current => ({ ...current, ribGPerPiece: event.target.value }))} placeholder="ex: 18" className={inputClassName} /></label>
|
||||
<label className={labelClassName}>Custo R$/kg<input inputMode="decimal" value={referenceForm.materialCostPerKg} onChange={(event) => setReferenceForm(current => ({ ...current, materialCostPerKg: event.target.value }))} placeholder="opcional" className={inputClassName} /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-bold text-dark-muted">Rendimento por tamanho</p>
|
||||
<span className="text-[11px] font-semibold text-dark-muted">area m² / pç/kg</span>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-dark-border">
|
||||
<div className="grid grid-cols-[56px_1fr_1fr] gap-2 border-b border-dark-border bg-dark-input px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-dark-muted">
|
||||
<span>Tam.</span>
|
||||
<span>Area</span>
|
||||
<span>Rendimento</span>
|
||||
</div>
|
||||
{referenceSizes.map(size => (
|
||||
<div key={size} className="grid grid-cols-[56px_1fr_1fr] items-center gap-2 border-b border-dark-border px-3 py-2 last:border-b-0">
|
||||
<div className="text-xs font-bold text-dark-text">{size}</div>
|
||||
<input value={sizeAreas[size] || ''} onChange={(event) => setSizeAreas(current => ({ ...current, [size]: event.target.value }))} placeholder="area" className="h-8 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary" />
|
||||
<input value={sizeYields[size] || ''} onChange={(event) => setSizeYields(current => ({ ...current, [size]: event.target.value }))} placeholder="pç/kg" className="h-8 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => void saveReference()} disabled={status === 'saving'} className="inline-flex h-11 w-full 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-50 cursor-pointer">
|
||||
<Save className="h-4 w-4" />
|
||||
Salvar referencia
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Registrations;
|
||||
635
src/pages/Replenishment.tsx
Normal file
635
src/pages/Replenishment.tsx
Normal file
@@ -0,0 +1,635 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Download, Eye, Package, Pencil, Search, TrendingUp } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { buildSkuEditPath } from '../catalogLinks';
|
||||
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
||||
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
|
||||
type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock';
|
||||
type ReplenishmentFilter = 'all' | ReplenishmentStatus;
|
||||
type ReplenishmentSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'coverage_asc' | 'sold_desc' | 'name_asc';
|
||||
type ReplenishmentView = 'sku' | 'group';
|
||||
|
||||
type ReplenishmentRow = ProductAnalyticsItem & {
|
||||
dailySales: number;
|
||||
projectedDemand: number;
|
||||
suggestedQuantity: number;
|
||||
openProductionQuantity: number;
|
||||
availableQuantity: number;
|
||||
daysOfCover: number | null;
|
||||
status: ReplenishmentStatus;
|
||||
statusLabel: string;
|
||||
baseName: string;
|
||||
color: string;
|
||||
size: string;
|
||||
productIds: string[];
|
||||
productCount: number;
|
||||
sizes: string[];
|
||||
};
|
||||
|
||||
const statusStyles: Record<ReplenishmentStatus, { label: string; className: string; dotClass: string }> = {
|
||||
need: {
|
||||
label: 'Repor',
|
||||
className: 'border-red-400/35 bg-red-400/10 text-red-300',
|
||||
dotClass: 'bg-red-400'
|
||||
},
|
||||
no_stock: {
|
||||
label: 'Sem estoque',
|
||||
className: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-300',
|
||||
dotClass: 'bg-zinc-400'
|
||||
},
|
||||
covered: {
|
||||
label: 'Coberto',
|
||||
className: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-300',
|
||||
dotClass: 'bg-emerald-400'
|
||||
},
|
||||
no_sales: {
|
||||
label: 'Sem venda',
|
||||
className: 'border-dark-border bg-dark-input text-dark-muted',
|
||||
dotClass: 'bg-dark-muted'
|
||||
}
|
||||
};
|
||||
|
||||
const filterOptions: Array<{ value: ReplenishmentFilter; label: string }> = [
|
||||
{ value: 'need', label: 'Com necessidade' },
|
||||
{ value: 'all', label: 'Todos' },
|
||||
{ value: 'no_stock', label: 'Sem estoque' },
|
||||
{ value: 'covered', label: 'Cobertos' },
|
||||
{ value: 'no_sales', label: 'Sem venda' }
|
||||
];
|
||||
|
||||
const coverageTargetOptions = [7, 15, 30, 60];
|
||||
|
||||
const formatNumber = (value: number, maximumFractionDigits = 0) => (
|
||||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).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 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 allProductionOrdersRange = {
|
||||
start: new Date(2000, 0, 1),
|
||||
end: new Date(2100, 11, 31)
|
||||
};
|
||||
|
||||
const ReplenishmentSkeleton = () => (
|
||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm" aria-label="Carregando necessidade de reposicao">
|
||||
<div className="border-b border-zinc-100 p-4 dark:border-dark-border">
|
||||
<div className="grid grid-cols-[120px_1.5fr_120px_130px_120px_130px_130px_110px] gap-6">
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map(item => (
|
||||
<div key={`replenishment-head-skeleton-${item}`} className="skeleton h-3" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
|
||||
<div key={`replenishment-row-skeleton-${row}`} className="grid grid-cols-[120px_1.5fr_120px_130px_120px_130px_130px_110px] gap-6 px-6 py-4">
|
||||
<div className="skeleton h-4" />
|
||||
<div>
|
||||
<div className="skeleton h-4 w-4/5" />
|
||||
<div className="skeleton mt-2 h-3 w-32" />
|
||||
</div>
|
||||
<div className="skeleton h-6 rounded-full" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-7 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Replenishment = () => {
|
||||
const { dateRange, setDateRange } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void
|
||||
}>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<ReplenishmentFilter>(() => {
|
||||
const filter = searchParams.get('status') as ReplenishmentFilter | null;
|
||||
return filter && filterOptions.some(option => option.value === filter) ? filter : 'need';
|
||||
});
|
||||
const [sortBy, setSortBy] = useState<ReplenishmentSort>('need_desc');
|
||||
const [viewMode, setViewMode] = useState<ReplenishmentView>(() => searchParams.get('view') === 'group' ? 'group' : 'sku');
|
||||
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
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 allRows = useMemo<ReplenishmentRow[]>(() => {
|
||||
const rangeDays = getRangeDays(dateRange);
|
||||
|
||||
return products.map(product => {
|
||||
const dailySales = product.quantitySold / rangeDays;
|
||||
const projectedDemand = dailySales * targetCoverageDays;
|
||||
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
||||
const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity;
|
||||
const rawNeed = projectedDemand - availableQuantity;
|
||||
const suggestedQuantity = Math.max(0, Math.ceil(rawNeed));
|
||||
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
|
||||
const status: ReplenishmentStatus = availableQuantity <= 0
|
||||
? 'no_stock'
|
||||
: dailySales <= 0
|
||||
? 'no_sales'
|
||||
: suggestedQuantity > 0
|
||||
? 'need'
|
||||
: 'covered';
|
||||
|
||||
const metadata = parseProductName(product.name);
|
||||
|
||||
return {
|
||||
...product,
|
||||
dailySales,
|
||||
projectedDemand,
|
||||
suggestedQuantity,
|
||||
openProductionQuantity,
|
||||
availableQuantity,
|
||||
daysOfCover,
|
||||
status,
|
||||
statusLabel: statusStyles[status].label,
|
||||
baseName: metadata.baseName,
|
||||
color: metadata.color,
|
||||
size: metadata.size,
|
||||
productIds: [product.id],
|
||||
productCount: 1,
|
||||
sizes: metadata.size ? [metadata.size] : []
|
||||
};
|
||||
});
|
||||
}, [dateRange, openProductionByProductId, products, targetCoverageDays]);
|
||||
|
||||
const groupedRows = useMemo<ReplenishmentRow[]>(() => {
|
||||
const groups = new Map<string, ReplenishmentRow[]>();
|
||||
|
||||
allRows.forEach(row => {
|
||||
const key = `${row.baseName.toLowerCase()}::${row.color.toLowerCase()}`;
|
||||
const group = groups.get(key) || [];
|
||||
group.push(row);
|
||||
groups.set(key, group);
|
||||
});
|
||||
|
||||
return Array.from(groups.values()).map(group => {
|
||||
const first = group[0];
|
||||
const quantitySold = group.reduce((total, row) => total + row.quantitySold, 0);
|
||||
const revenue = group.reduce((total, row) => total + row.revenue, 0);
|
||||
const stock = group.reduce((total, row) => total + row.stock, 0);
|
||||
const openProductionQuantity = group.reduce((total, row) => total + row.openProductionQuantity, 0);
|
||||
const availableQuantity = group.reduce((total, row) => total + row.availableQuantity, 0);
|
||||
const dailySales = group.reduce((total, row) => total + row.dailySales, 0);
|
||||
const projectedDemand = group.reduce((total, row) => total + row.projectedDemand, 0);
|
||||
const suggestedQuantity = group.reduce((total, row) => total + row.suggestedQuantity, 0);
|
||||
const orderLineCount = group.reduce((total, row) => total + row.orderLineCount, 0);
|
||||
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
|
||||
const status: ReplenishmentStatus = availableQuantity <= 0
|
||||
? 'no_stock'
|
||||
: dailySales <= 0
|
||||
? 'no_sales'
|
||||
: suggestedQuantity > 0
|
||||
? 'need'
|
||||
: 'covered';
|
||||
const sizes = sortProductSizes(Array.from(new Set(group.flatMap(row => row.sizes))));
|
||||
const productIds = group.map(row => row.id);
|
||||
const name = first.color ? `${first.baseName} · ${formatColorLabel(first.color)}` : first.baseName;
|
||||
|
||||
return {
|
||||
...first,
|
||||
id: productIds[0],
|
||||
name,
|
||||
quantitySold,
|
||||
revenue,
|
||||
stock,
|
||||
openProductionQuantity,
|
||||
availableQuantity,
|
||||
orderLineCount,
|
||||
dailySales,
|
||||
projectedDemand,
|
||||
suggestedQuantity,
|
||||
daysOfCover,
|
||||
status,
|
||||
statusLabel: statusStyles[status].label,
|
||||
productIds,
|
||||
productCount: group.length,
|
||||
sizes,
|
||||
lastPrice: group.length ? revenue / Math.max(1, quantitySold) : first.lastPrice
|
||||
};
|
||||
});
|
||||
}, [allRows]);
|
||||
|
||||
const activeRows = viewMode === 'group' ? groupedRows : allRows;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||
const searchedRows = normalizedSearch
|
||||
? activeRows.filter(row =>
|
||||
row.name.toLowerCase().includes(normalizedSearch) ||
|
||||
row.id.toLowerCase().includes(normalizedSearch) ||
|
||||
row.productIds.some(id => id.toLowerCase().includes(normalizedSearch))
|
||||
)
|
||||
: activeRows;
|
||||
|
||||
const statusRows = statusFilter === 'all'
|
||||
? searchedRows
|
||||
: searchedRows.filter(row => (
|
||||
statusFilter === 'need'
|
||||
? row.suggestedQuantity > 0
|
||||
: row.status === statusFilter
|
||||
));
|
||||
|
||||
return [...statusRows].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'need_asc': return a.suggestedQuantity - b.suggestedQuantity;
|
||||
case 'demand_desc': return b.projectedDemand - a.projectedDemand;
|
||||
case 'stock_asc': return a.stock - b.stock;
|
||||
case 'coverage_asc': return (a.daysOfCover ?? Number.POSITIVE_INFINITY) - (b.daysOfCover ?? Number.POSITIVE_INFINITY);
|
||||
case 'sold_desc': return b.quantitySold - a.quantitySold;
|
||||
case 'name_asc': return a.name.localeCompare(b.name, 'pt-BR');
|
||||
case 'need_desc':
|
||||
default:
|
||||
return b.suggestedQuantity - a.suggestedQuantity;
|
||||
}
|
||||
});
|
||||
}, [activeRows, searchTerm, sortBy, statusFilter]);
|
||||
|
||||
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||
const paginatedRows = filteredRows.slice(startIndex, startIndex + itemsPerPage);
|
||||
const isRefreshing = isLoading && products.length > 0;
|
||||
|
||||
const needRows = activeRows.filter(row => row.suggestedQuantity > 0);
|
||||
const totalSuggestedQuantity = needRows.reduce((total, row) => total + row.suggestedQuantity, 0);
|
||||
const projectedDemand = activeRows.reduce((total, row) => total + row.projectedDemand, 0);
|
||||
const totalStock = activeRows.reduce((total, row) => total + row.stock, 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>
|
||||
<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 mb-2 text-zinc-900 dark:text-dark-text">Necessidade de Reposição</h1>
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">
|
||||
O período selecionado define a base de vendas; a cobertura define quantos dias de estoque sugerir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
onChange={(range) => {
|
||||
setDateRange(range);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const exportData = filteredRows.map(row => ({
|
||||
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
|
||||
'ID Produto': viewMode === 'group' ? row.productIds.join(' | ') : row.id,
|
||||
'Descricao': row.name,
|
||||
'Cor': row.color,
|
||||
'Tamanhos': row.sizes.join(' | '),
|
||||
'SKUs': row.productCount,
|
||||
'Status': row.statusLabel,
|
||||
'Cobertura alvo (dias)': targetCoverageDays,
|
||||
'Vendido no Periodo': row.quantitySold,
|
||||
'Media Diaria': row.dailySales.toFixed(2).replace('.', ','),
|
||||
'Demanda Projetada': row.projectedDemand.toFixed(2).replace('.', ','),
|
||||
'Estoque Atual': row.stock,
|
||||
'OP Aberta': row.openProductionQuantity,
|
||||
'Disponivel': row.availableQuantity,
|
||||
'Cobertura Atual': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
|
||||
'Sugestao Reposicao': row.suggestedQuantity
|
||||
}));
|
||||
exportToCSV(exportData, `necessidade_reposicao_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
}}
|
||||
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
|
||||
title="Exportar para CSV"
|
||||
>
|
||||
<Download size={16} className="text-brand-primary" />
|
||||
<span className="hidden sm:inline">Exportar</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RefreshStatus isRefreshing={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">Produtos a repor</p>
|
||||
<p className="mt-2 text-3xl font-bold text-red-300">{formatNumber(needRows.length)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{viewMode === 'group' ? 'Grupos' : 'SKUs'} abaixo da demanda projetada
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-red-400/25 bg-red-400/10 p-3 text-red-300">
|
||||
<AlertTriangle 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">Sugestão total</p>
|
||||
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalSuggestedQuantity)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Unidades para cobrir {targetCoverageDays} dias</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-brand-primary/25 bg-brand-primary/10 p-3 text-brand-primary">
|
||||
<Package 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">Demanda projetada</p>
|
||||
<p className="mt-2 text-3xl font-bold text-sky-300">{formatNumber(projectedDemand)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Pelo ritmo do período selecionado</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-sky-400/25 bg-sky-400/10 p-3 text-sky-300">
|
||||
<TrendingUp 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">{viewMode === 'group' ? 'Grupos cobertos' : 'Produtos cobertos'}</p>
|
||||
<p className="mt-2 text-3xl font-bold text-emerald-300">{formatNumber(activeRows.filter(row => row.status === 'covered').length)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(totalStock)} unidades em estoque</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>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[auto_1fr_150px_180px_190px]">
|
||||
<div className="inline-flex rounded-xl border border-dark-border bg-dark-input p-1">
|
||||
{[
|
||||
{ key: 'sku' as const, label: 'SKU' },
|
||||
{ key: 'group' as const, label: 'Grupo' }
|
||||
].map(view => (
|
||||
<button
|
||||
key={view.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewMode(view.key);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-bold transition-colors cursor-pointer ${
|
||||
viewMode === view.key
|
||||
? 'bg-brand-primary text-brand-contrast'
|
||||
: 'text-dark-muted hover:bg-dark-card hover:text-dark-text'
|
||||
}`}
|
||||
>
|
||||
{view.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nome ou ID..."
|
||||
value={searchTerm}
|
||||
onChange={(event) => {
|
||||
setSearchTerm(event.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full bg-dark-input border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={targetCoverageDays}
|
||||
onChange={(event) => {
|
||||
setTargetCoverageDays(Number(event.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:outline-none focus:border-brand-primary cursor-pointer"
|
||||
aria-label="Dias de cobertura alvo"
|
||||
>
|
||||
{coverageTargetOptions.map(days => (
|
||||
<option key={days} value={days}>Cobrir {days} dias</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => {
|
||||
setStatusFilter(event.target.value as ReplenishmentFilter);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:outline-none focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
{filterOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(event) => {
|
||||
setSortBy(event.target.value as ReplenishmentSort);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:outline-none focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
<option value="need_desc">Maior necessidade</option>
|
||||
<option value="need_asc">Menor necessidade</option>
|
||||
<option value="demand_desc">Maior demanda projetada</option>
|
||||
<option value="stock_asc">Menor estoque</option>
|
||||
<option value="coverage_asc">Menor cobertura</option>
|
||||
<option value="sold_desc">Mais vendidos</option>
|
||||
<option value="name_asc">Nome A-Z</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading && products.length === 0 ? (
|
||||
<ReplenishmentSkeleton />
|
||||
) : (
|
||||
<div className={`bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1320px] table-fixed text-left text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[390px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[150px]" />
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[140px]" />
|
||||
</colgroup>
|
||||
<thead className="bg-zinc-50 dark:bg-dark-header border-b border-zinc-100 dark:border-dark-border text-zinc-500 dark:text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">{viewMode === 'group' ? 'SKUs' : 'ID Produto'}</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">{viewMode === 'group' ? 'Grupo / cor' : 'Descrição'}</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]">Demanda proj.</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Estoque</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Disponível</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Sugestão reposição</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Cobertura</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-zinc-100 dark:divide-dark-border">
|
||||
{paginatedRows.map(row => {
|
||||
const style = statusStyles[row.status];
|
||||
|
||||
return (
|
||||
<tr key={row.id} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors">
|
||||
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">
|
||||
{viewMode === 'group' ? `${row.productCount} SKUs` : `#${row.id}`}
|
||||
</td>
|
||||
<td className="max-w-0 px-6 py-2.5">
|
||||
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={row.name}>{row.name}</div>
|
||||
<div className="text-[10px] text-zinc-400 dark:text-dark-muted font-medium">
|
||||
{viewMode === 'group' && row.sizes.length ? `Tamanhos: ${row.sizes.join(', ')} · ` : ''}
|
||||
Média: {formatNumber(row.dailySales, 2)} un./dia · Vendido: {formatNumber(row.quantitySold)} un.
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<span className={`inline-flex items-center gap-2 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${style.className}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${style.dotClass}`} />
|
||||
{style.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatNumber(row.projectedDemand, 1)} un.</td>
|
||||
<td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatNumber(row.stock)} un.</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap">
|
||||
<span className="font-bold text-zinc-900 dark:text-dark-text">{formatNumber(row.availableQuantity)} un.</span>
|
||||
{!!row.openProductionQuantity && (
|
||||
<span className="ml-1 text-xs font-semibold text-dark-muted">OP {formatNumber(row.openProductionQuantity)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap">
|
||||
<span className={row.suggestedQuantity > 0 ? 'font-bold text-red-300' : 'font-bold text-emerald-300'}>
|
||||
{formatNumber(row.suggestedQuantity)} un.
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatDays(row.daysOfCover)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{viewMode === 'sku' && (
|
||||
<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 cursor-pointer"
|
||||
title={`Editar SKU ${row.id}`}
|
||||
aria-label={`Editar SKU ${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={viewMode === 'group' ? `/products/groups/${encodeProductGroupKey(row.baseName)}` : `/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={viewMode === 'group' ? `Ver grupo ${row.baseName}` : `Ver SKU ${row.id}`}
|
||||
aria-label={viewMode === 'group' ? `Ver grupo ${row.baseName}` : `Ver SKU ${row.id}`}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!filteredRows.length && (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<Package className="mx-auto h-10 w-10 text-dark-muted" />
|
||||
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum produto encontrado.</p>
|
||||
<p className="mt-1 text-sm text-dark-muted">Ajuste os filtros, a busca ou o período selecionado.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PaginationControls
|
||||
totalItems={filteredRows.length}
|
||||
currentPage={safeCurrentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={itemsPerPage}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
itemLabel="produtos"
|
||||
pageSizeLabel="itens por página"
|
||||
startIndex={startIndex}
|
||||
endIndex={Math.min(startIndex + itemsPerPage, filteredRows.length)}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(pageSize) => {
|
||||
setItemsPerPage(pageSize);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Replenishment;
|
||||
@@ -1,23 +1,46 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight, Download, Filter, Loader2, Search, Users } from 'lucide-react';
|
||||
import { Download, Filter, Search, Users } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import { exportToCSV, fetchRfmAnalytics } from '../dataService';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { exportToCSV, fetchRfmAnalytics, getCachedRfmAnalytics } from '../dataService';
|
||||
import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types';
|
||||
|
||||
const emptyClients: RfmClient[] = [];
|
||||
const emptySegments: RfmSegment[] = [];
|
||||
|
||||
const segmentStyles: Record<string, { accent: string; bg: string; text: string }> = {
|
||||
champions: { accent: 'bg-emerald-500', bg: 'bg-emerald-500/10', text: 'text-emerald-400' },
|
||||
potential_loyalists: { accent: 'bg-sky-500', bg: 'bg-sky-500/10', text: 'text-sky-400' },
|
||||
new_customers: { accent: 'bg-cyan-500', bg: 'bg-cyan-500/10', text: 'text-cyan-400' },
|
||||
loyal_customers: { accent: 'bg-blue-500', bg: 'bg-blue-500/10', text: 'text-blue-400' },
|
||||
need_attention: { accent: 'bg-amber-500', bg: 'bg-amber-500/10', text: 'text-amber-400' },
|
||||
about_to_sleep: { accent: 'bg-orange-500', bg: 'bg-orange-500/10', text: 'text-orange-400' },
|
||||
at_risk: { accent: 'bg-rose-500', bg: 'bg-rose-500/10', text: 'text-rose-400' },
|
||||
hibernating: { accent: 'bg-fuchsia-500', bg: 'bg-fuchsia-500/10', text: 'text-fuchsia-400' },
|
||||
lost: { accent: 'bg-zinc-500', bg: 'bg-zinc-500/10', text: 'text-zinc-400' }
|
||||
type SegmentStyle = {
|
||||
accent: string;
|
||||
bg: string;
|
||||
border: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ThemeMode = 'dark' | 'offwhite';
|
||||
|
||||
const darkSegmentStyles: Record<string, SegmentStyle> = {
|
||||
champions: { accent: '#18D6B5', bg: 'rgba(24, 214, 181, 0.10)', border: 'rgba(24, 214, 181, 0.34)', text: '#18D6B5' },
|
||||
potential_loyalists: { accent: '#A3E635', bg: 'rgba(163, 230, 53, 0.10)', border: 'rgba(163, 230, 53, 0.34)', text: '#A3E635' },
|
||||
new_customers: { accent: '#25C2FF', bg: 'rgba(37, 194, 255, 0.10)', border: 'rgba(37, 194, 255, 0.34)', text: '#25C2FF' },
|
||||
loyal_customers: { accent: '#6EA5FF', bg: 'rgba(110, 165, 255, 0.10)', border: 'rgba(110, 165, 255, 0.34)', text: '#6EA5FF' },
|
||||
need_attention: { accent: '#FFC247', bg: 'rgba(255, 194, 71, 0.11)', border: 'rgba(255, 194, 71, 0.34)', text: '#FFC247' },
|
||||
about_to_sleep: { accent: '#FF8A3D', bg: 'rgba(255, 138, 61, 0.11)', border: 'rgba(255, 138, 61, 0.34)', text: '#FF8A3D' },
|
||||
at_risk: { accent: '#FF5D7D', bg: 'rgba(255, 93, 125, 0.11)', border: 'rgba(255, 93, 125, 0.34)', text: '#FF5D7D' },
|
||||
hibernating: { accent: '#C77DFF', bg: 'rgba(199, 125, 255, 0.11)', border: 'rgba(199, 125, 255, 0.34)', text: '#C77DFF' },
|
||||
lost: { accent: '#8c9298', bg: 'rgba(140, 146, 152, 0.08)', border: 'rgba(140, 146, 152, 0.24)', text: '#b3b7bb' }
|
||||
};
|
||||
|
||||
const offwhiteSegmentStyles: Record<string, SegmentStyle> = {
|
||||
champions: { accent: '#138B75', bg: 'rgba(19, 139, 117, 0.10)', border: 'rgba(19, 139, 117, 0.34)', text: '#138B75' },
|
||||
potential_loyalists: { accent: '#5F8F12', bg: 'rgba(95, 143, 18, 0.11)', border: 'rgba(95, 143, 18, 0.34)', text: '#5F8F12' },
|
||||
new_customers: { accent: '#0B7EA8', bg: 'rgba(11, 126, 168, 0.10)', border: 'rgba(11, 126, 168, 0.34)', text: '#0B7EA8' },
|
||||
loyal_customers: { accent: '#356DCC', bg: 'rgba(53, 109, 204, 0.10)', border: 'rgba(53, 109, 204, 0.34)', text: '#356DCC' },
|
||||
need_attention: { accent: '#A36C00', bg: 'rgba(163, 108, 0, 0.11)', border: 'rgba(163, 108, 0, 0.34)', text: '#A36C00' },
|
||||
about_to_sleep: { accent: '#B75B12', bg: 'rgba(183, 91, 18, 0.11)', border: 'rgba(183, 91, 18, 0.34)', text: '#B75B12' },
|
||||
at_risk: { accent: '#C73656', bg: 'rgba(199, 54, 86, 0.10)', border: 'rgba(199, 54, 86, 0.34)', text: '#C73656' },
|
||||
hibernating: { accent: '#8C52D6', bg: 'rgba(140, 82, 214, 0.10)', border: 'rgba(140, 82, 214, 0.34)', text: '#8C52D6' },
|
||||
lost: { accent: '#757C82', bg: 'rgba(117, 124, 130, 0.08)', border: 'rgba(117, 124, 130, 0.26)', text: '#757C82' }
|
||||
};
|
||||
|
||||
const rfmSegmentDefinitions: Array<Pick<RfmSegment, 'key' | 'label'>> = [
|
||||
@@ -33,15 +56,15 @@ const rfmSegmentDefinitions: Array<Pick<RfmSegment, 'key' | 'label'>> = [
|
||||
];
|
||||
|
||||
const segmentDescriptions: Record<string, string> = {
|
||||
champions: 'Recentes, frequentes e valiosos',
|
||||
potential_loyalists: 'Recentes com bom potencial',
|
||||
champions: 'Recentes, recorrentes e valiosos',
|
||||
potential_loyalists: 'Recentes e em evolução',
|
||||
new_customers: 'Primeira compra recente',
|
||||
loyal_customers: 'Valiosos, mas menos recentes',
|
||||
need_attention: 'Base intermediária para nutrir',
|
||||
about_to_sleep: 'Baixo valor começando a esfriar',
|
||||
at_risk: 'Valiosos sem compra recente',
|
||||
hibernating: 'Sem compra recente e valor médio',
|
||||
lost: 'Baixa atividade e distante'
|
||||
loyal_customers: 'Bom histórico, compra entre 8 e 15 dias',
|
||||
need_attention: 'Perfil médio, compra entre 8 e 15 dias',
|
||||
about_to_sleep: 'Perfil baixo, compra entre 8 e 15 dias',
|
||||
at_risk: 'Histórico forte, sem compra entre 16 e 29 dias',
|
||||
hibernating: 'Sem compra entre 16 e 29 dias',
|
||||
lost: 'Sem compra há 30 dias ou mais'
|
||||
};
|
||||
|
||||
const segmentActions: Record<string, string> = {
|
||||
@@ -72,6 +95,23 @@ const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
};
|
||||
|
||||
const segmentPanelStyle = (style: SegmentStyle, isActive = false) => ({
|
||||
backgroundColor: style.bg,
|
||||
borderColor: isActive ? style.text : style.border,
|
||||
boxShadow: isActive ? `0 0 0 1px ${style.border}, 0 0 14px ${style.accent}22` : undefined
|
||||
});
|
||||
|
||||
const segmentPillStyle = (style: SegmentStyle) => ({
|
||||
backgroundColor: style.bg,
|
||||
color: style.text,
|
||||
borderColor: style.border
|
||||
});
|
||||
|
||||
const segmentDotStyle = (style: SegmentStyle) => ({
|
||||
backgroundColor: style.accent,
|
||||
boxShadow: `0 0 6px ${style.accent}55`
|
||||
});
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const parseLocalDate = (value: string) => {
|
||||
@@ -119,25 +159,143 @@ const ScoreBadge = ({ value }: { value: number }) => (
|
||||
</span>
|
||||
);
|
||||
|
||||
const RfmSkeleton = () => (
|
||||
<div className="space-y-6" aria-label="Carregando RFV">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[0, 1, 2].map(item => (
|
||||
<div key={`rfv-kpi-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||
<div className="skeleton h-4 w-36" />
|
||||
<div className="skeleton mt-3 h-8 w-24" />
|
||||
<div className="skeleton mt-3 h-3 w-44" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-6">
|
||||
<section className="bg-dark-card p-5 rounded-2xl border border-dark-border shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="skeleton h-5 w-28" />
|
||||
<div className="skeleton mt-2 h-3 w-72 max-w-full" />
|
||||
</div>
|
||||
<div className="skeleton h-3 w-44" />
|
||||
</div>
|
||||
<div className="mt-5 overflow-x-auto">
|
||||
<div className="grid min-w-[720px] grid-cols-[104px_repeat(3,minmax(0,1fr))] gap-2">
|
||||
{[0, 1, 2, 3].map(item => (
|
||||
<div key={`rfv-matrix-head-skeleton-${item}`} className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
||||
<div className="skeleton mx-auto h-3 w-16" />
|
||||
<div className="skeleton mx-auto mt-2 h-3 w-20" />
|
||||
</div>
|
||||
))}
|
||||
{[0, 1, 2].map(row => (
|
||||
<div key={`rfv-matrix-row-skeleton-${row}`} className="contents">
|
||||
{[0, 1, 2, 3].map(column => (
|
||||
<div
|
||||
key={`rfv-matrix-cell-skeleton-${row}-${column}`}
|
||||
className={`min-h-28 rounded-xl border border-dark-border bg-dark-input/50 p-3 ${
|
||||
column === 0 ? 'flex flex-col items-center justify-center text-center' : ''
|
||||
}`}
|
||||
>
|
||||
{column === 0 ? (
|
||||
<>
|
||||
<div className="skeleton h-3 w-12" />
|
||||
<div className="skeleton mt-2 h-4 w-12" />
|
||||
<div className="skeleton mt-3 h-3 w-14" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="skeleton h-2 w-8" />
|
||||
<div className="skeleton mt-5 h-4 w-28" />
|
||||
<div className="skeleton mt-2 h-3 w-36" />
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<div className="skeleton h-5" />
|
||||
<div className="skeleton h-5" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||
<div className="skeleton h-5 w-28" />
|
||||
<div className="mt-5 space-y-2">
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => (
|
||||
<div key={`rfv-segment-skeleton-${item}`} className="rounded-xl border border-dark-border p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-1 items-center gap-3">
|
||||
<div className="skeleton h-2.5 w-2.5 rounded-full" />
|
||||
<div className="skeleton h-4 w-32" />
|
||||
</div>
|
||||
<div className="skeleton h-4 w-8" />
|
||||
</div>
|
||||
<div className="skeleton mt-3 h-3 w-48" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
||||
<div className="flex flex-col gap-3 border-b border-dark-border p-4 sm:flex-row">
|
||||
<div className="skeleton h-10 w-48" />
|
||||
<div className="skeleton h-10 w-72" />
|
||||
<div className="skeleton h-10 w-28 sm:ml-auto" />
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-[1.5fr_1fr_120px_1fr_1fr_1fr_1fr] gap-5">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map(item => (
|
||||
<div key={`rfv-table-head-skeleton-${item}`} className="skeleton h-3" />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 space-y-5">
|
||||
{[0, 1, 2, 3, 4, 5].map(row => (
|
||||
<div key={`rfv-table-row-skeleton-${row}`} className="grid grid-cols-[1.5fr_1fr_120px_1fr_1fr_1fr_1fr] gap-5">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map(column => (
|
||||
<div key={`rfv-table-cell-skeleton-${row}-${column}`} className="skeleton h-4" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Rfm = () => {
|
||||
const { dateRange, setDateRange, refreshInterval, setRefreshInterval } = useOutletContext<{
|
||||
const { dateRange, setDateRange, refreshInterval, setRefreshInterval, themeMode } = useOutletContext<{
|
||||
dateRange: DateRange;
|
||||
setDateRange: (range: DateRange) => void;
|
||||
refreshInterval: number;
|
||||
setRefreshInterval: (interval: number) => void;
|
||||
themeMode?: ThemeMode;
|
||||
}>();
|
||||
const segmentStyles = themeMode === 'offwhite' ? offwhiteSegmentStyles : darkSegmentStyles;
|
||||
|
||||
const [analytics, setAnalytics] = useState<RfmAnalytics | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const initialCachedAnalytics = getCachedRfmAnalytics(dateRange);
|
||||
const [analytics, setAnalytics] = useState<RfmAnalytics | null>(initialCachedAnalytics || null);
|
||||
const [isLoading, setIsLoading] = useState(!initialCachedAnalytics);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [segmentFilter, setSegmentFilter] = useState('all');
|
||||
const [selectedSegmentKey, setSelectedSegmentKey] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
|
||||
const loadRfm = useCallback(async (range: DateRange) => {
|
||||
const loadRfm = useCallback(async (range: DateRange, options?: { force?: boolean }) => {
|
||||
const cachedAnalytics = options?.force ? undefined : getCachedRfmAnalytics(range);
|
||||
if (cachedAnalytics) {
|
||||
setAnalytics(cachedAnalytics);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const data = await fetchRfmAnalytics(range);
|
||||
const data = await fetchRfmAnalytics(range, undefined, options);
|
||||
setAnalytics(data);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
@@ -152,12 +310,45 @@ const Rfm = () => {
|
||||
if (refreshInterval === 0) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
void loadRfm(dateRange);
|
||||
void loadRfm(dateRange, { force: true });
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [dateRange, loadRfm, refreshInterval]);
|
||||
|
||||
const clearSegmentFocus = useCallback(() => {
|
||||
setSegmentFilter('all');
|
||||
setSelectedSegmentKey('');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const selectSegment = useCallback((segmentKey: string) => {
|
||||
if (segmentFilter === segmentKey) {
|
||||
clearSegmentFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
setSegmentFilter(segmentKey);
|
||||
setSelectedSegmentKey(segmentKey);
|
||||
setCurrentPage(1);
|
||||
}, [clearSegmentFocus, segmentFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (segmentFilter === 'all' && !selectedSegmentKey) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
clearSegmentFocus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [clearSegmentFocus, segmentFilter, selectedSegmentKey]);
|
||||
|
||||
const visibleAnalytics = analytics;
|
||||
const clients = visibleAnalytics?.clients || emptyClients;
|
||||
const segments = useMemo(() => {
|
||||
@@ -203,7 +394,8 @@ const Rfm = () => {
|
||||
}, [clients, searchTerm, segmentFilter]);
|
||||
|
||||
const totalPages = Math.ceil(filteredClients.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||
const paginatedClients = filteredClients.slice(startIndex, startIndex + itemsPerPage);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
@@ -261,6 +453,8 @@ const Rfm = () => {
|
||||
|
||||
exportToCSV(exportData, `rfv_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
};
|
||||
const shouldShowSkeleton = isLoading && !analytics;
|
||||
const isRefreshing = isLoading && Boolean(analytics);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -276,23 +470,23 @@ const Rfm = () => {
|
||||
onChange={setDateRange}
|
||||
refreshInterval={refreshInterval}
|
||||
setRefreshInterval={setRefreshInterval}
|
||||
onManualRefresh={() => void loadRfm(dateRange)}
|
||||
onManualRefresh={() => void loadRfm(dateRange, { force: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="inline-flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||
Atualizando RFV
|
||||
</div>
|
||||
)}
|
||||
<RefreshStatus isRefreshing={isRefreshing} />
|
||||
|
||||
{shouldShowSkeleton ? (
|
||||
<RfmSkeleton />
|
||||
) : (
|
||||
<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">
|
||||
<p className="text-dark-muted text-sm font-medium mb-1">Clientes no Período</p>
|
||||
<h3 className="text-3xl font-bold text-dark-text">{clients.length}</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Agrupados pela tag RFV anterior</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Segmento RFV calculado até o fim do período</p>
|
||||
</div>
|
||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||
<p className="text-dark-muted text-sm font-medium mb-1">Receita no Período</p>
|
||||
@@ -307,167 +501,165 @@ const Rfm = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-6">
|
||||
<section className="bg-dark-card p-5 rounded-2xl border border-dark-border shadow-sm">
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-dark-text">Matriz RFV</h2>
|
||||
<p className="text-sm font-medium text-dark-muted">Compradores do período agrupados pela tag RFV anterior ao período.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-dark-muted">
|
||||
<span>Menor prioridade</span>
|
||||
<span className="h-2 w-12 rounded-full bg-gradient-to-r from-rose-500 via-amber-400 to-emerald-500" />
|
||||
<span>Maior prioridade</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid min-w-[720px] grid-cols-[104px_repeat(3,minmax(0,1fr))] gap-2">
|
||||
<div className="flex items-center justify-center rounded-xl border border-dark-border bg-dark-input/60 px-3 py-2.5 text-center">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Eixos</p>
|
||||
<p className="text-xs font-semibold text-dark-text">Recência / Valor</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-6">
|
||||
<section className="bg-dark-card p-5 rounded-2xl border border-dark-border shadow-sm">
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-dark-text">Matriz RFV</h2>
|
||||
<p className="text-sm font-medium text-dark-muted">Recência fixa, frequência por número de pedidos e valor relativo ao histórico.</p>
|
||||
</div>
|
||||
{[1, 2, 3].map(valueScore => (
|
||||
<div key={valueScore} className="rounded-xl border border-dark-border bg-dark-input/70 px-3 py-2.5 text-center">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Valor {valueScore}</p>
|
||||
<p className="text-xs font-bold text-dark-text">{scoreLabel(valueScore)}</p>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-dark-muted">
|
||||
<span>Menor prioridade</span>
|
||||
<span
|
||||
className="h-2 w-12 rounded-full"
|
||||
style={{ background: 'linear-gradient(90deg, #FF6B8A, #FFC247, #18D6B5)' }}
|
||||
/>
|
||||
<span>Maior prioridade</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{[3, 2, 1].map(recencyScore => (
|
||||
<div key={recencyScore} className="contents">
|
||||
<div className="flex min-h-28 items-center justify-center rounded-xl border border-dark-border bg-dark-input/70 px-2.5 text-center">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid min-w-[720px] grid-cols-[104px_repeat(3,minmax(0,1fr))] gap-2">
|
||||
<div className="flex items-center justify-center rounded-xl border border-dark-border bg-dark-input/60 px-3 py-2.5 text-center">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Rec. {recencyScore}</p>
|
||||
<p className="text-xs font-bold text-dark-text">{scoreLabel(recencyScore)}</p>
|
||||
<p className="mt-1 text-[10px] font-semibold text-dark-muted">
|
||||
{recencyScore === 3 ? 'Compra recente' : recencyScore === 2 ? 'Intermediário' : 'Distante'}
|
||||
</p>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Eixos</p>
|
||||
<p className="text-xs font-semibold text-dark-text">Recência / Perfil</p>
|
||||
</div>
|
||||
</div>
|
||||
{[1, 2, 3].map(valueScore => (
|
||||
<div key={valueScore} className="rounded-xl border border-dark-border bg-dark-input/70 px-3 py-2.5 text-center">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Perfil {valueScore}</p>
|
||||
<p className="text-xs font-bold text-dark-text">{scoreLabel(valueScore)}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{[1, 2, 3].map(valueScore => {
|
||||
const segment = segmentForCell(segments, recencyScore, valueScore);
|
||||
const style = segmentStyles[segment?.key || 'lost'];
|
||||
const isActive = segmentFilter === segment?.key;
|
||||
const intensity = segment?.count ? 'opacity-100' : 'opacity-75';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${recencyScore}-${valueScore}`}
|
||||
onClick={() => {
|
||||
setSegmentFilter(segment?.key || 'all');
|
||||
setSelectedSegmentKey(segment?.key || '');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className={`group min-h-28 rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 hover:border-brand-primary cursor-pointer ${
|
||||
isActive ? 'border-brand-primary ring-1 ring-brand-primary/50' : 'border-dark-border'
|
||||
} ${style.bg} ${intensity}`}
|
||||
>
|
||||
<div className="flex h-full flex-col justify-between gap-3">
|
||||
<div>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<span className={`h-2 w-8 rounded-full ${style.accent}`} />
|
||||
<span className="rounded-md border border-dark-border bg-black/10 px-1.5 py-0.5 text-[10px] font-bold text-dark-muted">
|
||||
R{recencyScore} V{valueScore}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-dark-text">{segment?.label}</p>
|
||||
<p className="mt-1 truncate text-xs font-medium text-dark-muted">
|
||||
{segmentDescriptions[segment?.key || 'lost']}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Clientes</p>
|
||||
<p className={`text-xl font-bold leading-none ${style.text}`}>{segment?.count || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Receita período</p>
|
||||
<p className="truncate text-xs font-bold text-dark-text">{formatCurrency(segment?.totalRevenue || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||
<h2 className="mb-5 text-lg font-bold text-dark-text">Segmentos</h2>
|
||||
<div className="space-y-2">
|
||||
{segments.map(segment => {
|
||||
const style = segmentStyles[segment.key] || segmentStyles.lost;
|
||||
return (
|
||||
<button
|
||||
key={segment.key}
|
||||
onClick={() => {
|
||||
setSegmentFilter(segment.key);
|
||||
setSelectedSegmentKey(segment.key);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className={`w-full rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${
|
||||
segmentFilter === segment.key ? 'border-brand-primary bg-dark-input' : 'border-dark-border bg-transparent hover:border-brand-primary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${style.accent}`} />
|
||||
<span className="truncate text-sm font-bold text-dark-text">{segment.label}</span>
|
||||
{[3, 2, 1].map(recencyScore => (
|
||||
<div key={recencyScore} className="contents">
|
||||
<div className="flex min-h-28 items-center justify-center rounded-xl border border-dark-border bg-dark-input/70 px-2.5 text-center">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Rec. {recencyScore}</p>
|
||||
<p className="text-xs font-bold text-dark-text">{scoreLabel(recencyScore)}</p>
|
||||
<p className="mt-1 text-[10px] font-semibold text-dark-muted">
|
||||
{recencyScore === 3 ? '0 a 7 dias' : recencyScore === 2 ? '8 a 15 dias' : '16+ dias'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-sm font-bold ${style.text}`}>{segment.count}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{(segmentInsights[segment.key]?.customerPercent || 0).toFixed(1)}% clientes · {formatCurrency(segment.totalRevenue)} no período
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{[1, 2, 3].map(valueScore => {
|
||||
const segment = segmentForCell(segments, recencyScore, valueScore);
|
||||
const segmentKey = segment?.key || 'lost';
|
||||
const style = segmentStyles[segmentKey];
|
||||
const isActive = segmentFilter === segmentKey;
|
||||
const intensity = segment?.count ? 'opacity-100' : 'opacity-75';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${recencyScore}-${valueScore}`}
|
||||
onClick={() => selectSegment(segmentKey)}
|
||||
className={`group min-h-28 rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 cursor-pointer ${intensity}`}
|
||||
style={segmentPanelStyle(style, isActive)}
|
||||
>
|
||||
<div className="flex h-full flex-col justify-between gap-3">
|
||||
<div>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<span className="h-2 w-8 rounded-full" style={segmentDotStyle(style)} />
|
||||
<span className="rounded-md border border-dark-border bg-black/10 px-1.5 py-0.5 text-[10px] font-bold text-dark-muted">
|
||||
R{recencyScore} P{valueScore}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-dark-text">{segment?.label}</p>
|
||||
<p className="mt-1 truncate text-xs font-medium text-dark-muted">
|
||||
{segmentDescriptions[segmentKey]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Clientes</p>
|
||||
<p className="text-xl font-bold leading-none" style={{ color: style.text }}>{segment?.count || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Receita período</p>
|
||||
<p className="truncate text-xs font-bold text-dark-text">{formatCurrency(segment?.totalRevenue || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||
<h2 className="mb-5 text-lg font-bold text-dark-text">Segmentos</h2>
|
||||
<div className="space-y-2">
|
||||
{segments.map(segment => {
|
||||
const style = segmentStyles[segment.key] || segmentStyles.lost;
|
||||
return (
|
||||
<button
|
||||
key={segment.key}
|
||||
onClick={() => selectSegment(segment.key)}
|
||||
className={`w-full rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${
|
||||
segmentFilter === segment.key ? '' : 'hover:border-brand-primary'
|
||||
}`}
|
||||
style={segmentFilter === segment.key ? segmentPanelStyle(style, true) : { borderColor: '#282828' }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={segmentDotStyle(style)} />
|
||||
<span className="truncate text-sm font-bold text-dark-text">{segment.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold" style={{ color: style.text }}>{segment.count}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{(segmentInsights[segment.key]?.customerPercent || 0).toFixed(1)}% clientes · {formatCurrency(segment.totalRevenue)} no período
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{selectedSegment && selectedSegmentInsights && (
|
||||
<section className="rounded-2xl border p-4 shadow-sm" style={segmentPanelStyle(selectedSegmentStyle, true)}>
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={segmentDotStyle(selectedSegmentStyle)} />
|
||||
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: selectedSegmentStyle.text }}>Segmento selecionado</p>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-dark-text">{selectedSegment.label}</h3>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">{segmentActions[selectedSegment.key]}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:w-[560px]">
|
||||
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Clientes</p>
|
||||
<p className="mt-1 text-base font-bold text-dark-text">{selectedSegmentInsights.customerPercent.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Receita período</p>
|
||||
<p className="mt-1 text-base font-bold text-dark-text">{selectedSegmentInsights.revenuePercent.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Ticket período</p>
|
||||
<p className="mt-1 text-base font-bold text-dark-text">{formatCurrency(selectedSegmentInsights.averageTicket)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Compra período</p>
|
||||
<p className="mt-1 text-base font-bold text-dark-text">{selectedSegmentInsights.averageRecencyDays.toFixed(0)} dias</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedSegment && selectedSegmentInsights && (
|
||||
<section className={`rounded-2xl border border-dark-border ${selectedSegmentStyle.bg} p-4 shadow-sm`}>
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${selectedSegmentStyle.accent}`} />
|
||||
<p className={`text-xs font-bold uppercase tracking-widest ${selectedSegmentStyle.text}`}>Segmento selecionado</p>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-dark-text">{selectedSegment.label}</h3>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">{segmentActions[selectedSegment.key]}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:w-[560px]">
|
||||
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Clientes</p>
|
||||
<p className="mt-1 text-base font-bold text-dark-text">{selectedSegmentInsights.customerPercent.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Receita período</p>
|
||||
<p className="mt-1 text-base font-bold text-dark-text">{selectedSegmentInsights.revenuePercent.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Ticket período</p>
|
||||
<p className="mt-1 text-base font-bold text-dark-text">{formatCurrency(selectedSegmentInsights.averageTicket)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Compra período</p>
|
||||
<p className="mt-1 text-base font-bold text-dark-text">{selectedSegmentInsights.averageRecencyDays.toFixed(0)} dias</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 border-b border-dark-border p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
@@ -531,9 +723,9 @@ const Rfm = () => {
|
||||
{paginatedClients.map((client: RfmClient) => {
|
||||
const style = segmentStyles[client.segmentKey] || segmentStyles.lost;
|
||||
return (
|
||||
<tr key={client.phone} className="hover:bg-dark-input/50 transition-colors">
|
||||
<tr key={client.customerKey} className="hover:bg-dark-input/50 transition-colors">
|
||||
<td className="px-6 py-3">
|
||||
<Link to={`/clients/${encodeURIComponent(client.name)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors">
|
||||
<Link to={`/clients/${encodeURIComponent(client.clientToken)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-dark-input text-dark-muted">
|
||||
<Users className="h-4 w-4" />
|
||||
</span>
|
||||
@@ -544,8 +736,8 @@ const Rfm = () => {
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-bold ${style.bg} ${style.text}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${style.accent}`} />
|
||||
<span className="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-bold" style={segmentPillStyle(style)}>
|
||||
<span className="h-2 w-2 rounded-full" style={segmentDotStyle(style)} />
|
||||
{client.segmentLabel}
|
||||
</span>
|
||||
</td>
|
||||
@@ -576,48 +768,26 @@ const Rfm = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-6 py-4 border-t border-dark-border flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-dark-muted">
|
||||
<span>Mostrar</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(event) => {
|
||||
setItemsPerPage(Number(event.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
<span>clientes por página</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-dark-muted">
|
||||
Mostrando {filteredClients.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + itemsPerPage, filteredClients.length)} de {filteredClients.length} clientes
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
|
||||
disabled={currentPage === 1}
|
||||
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"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(page => Math.min(totalPages, page + 1))}
|
||||
disabled={currentPage === totalPages || totalPages === 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"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PaginationControls
|
||||
totalItems={filteredClients.length}
|
||||
currentPage={safeCurrentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={itemsPerPage}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
itemLabel="clientes"
|
||||
pageSizeLabel="clientes por página"
|
||||
startIndex={startIndex}
|
||||
endIndex={Math.min(startIndex + itemsPerPage, filteredClients.length)}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(pageSize) => {
|
||||
setItemsPerPage(pageSize);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="px-6 py-4 border-t border-dark-border"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
2118
src/pages/Supplies.tsx
Normal file
2118
src/pages/Supplies.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
src/planningStock.ts
Normal file
1
src/planningStock.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const getPlanningStock = (stock: number) => Math.max(0, stock);
|
||||
177
src/productClassification.ts
Normal file
177
src/productClassification.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { normalizeProductText } from './productParsing.ts';
|
||||
|
||||
export type ProductTypeKey =
|
||||
| 'finished_apparel'
|
||||
| 'finished_accessory'
|
||||
| 'raw_material'
|
||||
| 'packaging'
|
||||
| 'dtf_input'
|
||||
| 'dtf_service'
|
||||
| 'kit_bundle'
|
||||
| 'service'
|
||||
| 'machine_part'
|
||||
| 'equipment'
|
||||
| 'ignore_from_planning'
|
||||
| 'unknown';
|
||||
|
||||
export type ProductPlanningMode = 'cutting' | 'unit_replenishment' | 'material' | 'service' | 'ignore' | 'review';
|
||||
|
||||
export type ProductTypeConfig = {
|
||||
key: ProductTypeKey;
|
||||
label: string;
|
||||
description: string;
|
||||
planningMode: ProductPlanningMode;
|
||||
badgeClassName: string;
|
||||
};
|
||||
|
||||
export const productTypeConfigs: Record<ProductTypeKey, ProductTypeConfig> = {
|
||||
finished_apparel: {
|
||||
key: 'finished_apparel',
|
||||
label: 'Vestuário',
|
||||
description: 'Produto acabado que pode entrar em corte/reposição por SKU.',
|
||||
planningMode: 'cutting',
|
||||
badgeClassName: 'border-sky-400/30 bg-sky-400/10 text-sky-300'
|
||||
},
|
||||
finished_accessory: {
|
||||
key: 'finished_accessory',
|
||||
label: 'Acessório',
|
||||
description: 'Produto acabado sem regra de malha principal.',
|
||||
planningMode: 'unit_replenishment',
|
||||
badgeClassName: 'border-cyan-400/30 bg-cyan-400/10 text-cyan-300'
|
||||
},
|
||||
raw_material: {
|
||||
key: 'raw_material',
|
||||
label: 'Matéria-prima',
|
||||
description: 'Entrada de produção, como malha, ribana, tecido, fio ou resíduo.',
|
||||
planningMode: 'material',
|
||||
badgeClassName: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
||||
},
|
||||
packaging: {
|
||||
key: 'packaging',
|
||||
label: 'Embalagem',
|
||||
description: 'Insumo de embalagem controlado por unidade/milheiro.',
|
||||
planningMode: 'unit_replenishment',
|
||||
badgeClassName: 'border-amber-400/30 bg-amber-400/10 text-amber-300'
|
||||
},
|
||||
dtf_input: {
|
||||
key: 'dtf_input',
|
||||
label: 'Insumo DTF',
|
||||
description: 'Insumo para DTF, como tinta, filme, poliamida ou fluido.',
|
||||
planningMode: 'material',
|
||||
badgeClassName: 'border-fuchsia-400/30 bg-fuchsia-400/10 text-fuchsia-300'
|
||||
},
|
||||
dtf_service: {
|
||||
key: 'dtf_service',
|
||||
label: 'Serviço DTF',
|
||||
description: 'Impressão, estampa ou personalização vendida como serviço/produto customizado.',
|
||||
planningMode: 'service',
|
||||
badgeClassName: 'border-purple-400/30 bg-purple-400/10 text-purple-300'
|
||||
},
|
||||
kit_bundle: {
|
||||
key: 'kit_bundle',
|
||||
label: 'Kit',
|
||||
description: 'Bundle marketplace composto por outros SKUs.',
|
||||
planningMode: 'review',
|
||||
badgeClassName: 'border-indigo-400/30 bg-indigo-400/10 text-indigo-300'
|
||||
},
|
||||
service: {
|
||||
key: 'service',
|
||||
label: 'Serviço',
|
||||
description: 'Frete, transporte, tecelagem, tinturaria ou serviço técnico.',
|
||||
planningMode: 'service',
|
||||
badgeClassName: 'border-zinc-400/30 bg-zinc-400/10 text-zinc-300'
|
||||
},
|
||||
machine_part: {
|
||||
key: 'machine_part',
|
||||
label: 'Peça máquina',
|
||||
description: 'Peça, limpeza ou manutenção de máquina.',
|
||||
planningMode: 'unit_replenishment',
|
||||
badgeClassName: 'border-orange-400/30 bg-orange-400/10 text-orange-300'
|
||||
},
|
||||
equipment: {
|
||||
key: 'equipment',
|
||||
label: 'Equipamento',
|
||||
description: 'Máquina ou equipamento permanente.',
|
||||
planningMode: 'ignore',
|
||||
badgeClassName: 'border-slate-400/30 bg-slate-400/10 text-slate-300'
|
||||
},
|
||||
ignore_from_planning: {
|
||||
key: 'ignore_from_planning',
|
||||
label: 'Ignorar',
|
||||
description: 'Item que não deve dirigir corte, compra ou reposição.',
|
||||
planningMode: 'ignore',
|
||||
badgeClassName: 'border-dark-border bg-dark-input text-dark-muted'
|
||||
},
|
||||
unknown: {
|
||||
key: 'unknown',
|
||||
label: 'Revisar',
|
||||
description: 'Tipo não identificado automaticamente.',
|
||||
planningMode: 'review',
|
||||
badgeClassName: 'border-red-400/30 bg-red-400/10 text-red-300'
|
||||
}
|
||||
};
|
||||
|
||||
const has = (value: string, pattern: RegExp) => pattern.test(value);
|
||||
|
||||
export const classifyProductType = (name: string): ProductTypeKey => {
|
||||
const normalizedName = normalizeProductText(name)
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toUpperCase();
|
||||
|
||||
if (!normalizedName) return 'unknown';
|
||||
|
||||
if (has(normalizedName, /\bSALDO ESTOQUE\b/)) return 'ignore_from_planning';
|
||||
if (has(normalizedName, /^(?:\d+\s+)?(?:MALHA|RIBANA)\b/)) return 'raw_material';
|
||||
if (has(normalizedName, /\b(?:RETALHO|RESIDUO)\s+(?:DE\s+)?(?:MALHA|MOLETOM)\b/)) return 'raw_material';
|
||||
if (has(normalizedName, /\bFIO\b.*\bMALHARIA\b/)) return 'raw_material';
|
||||
if (has(normalizedName, /\b(?:LINHA|FIO) PARA COSTURA\b/)) return 'raw_material';
|
||||
if (has(normalizedName, /\bATACADOR\b/)) return 'raw_material';
|
||||
if (has(normalizedName, /\bILHOS\b/)) return 'raw_material';
|
||||
if (has(normalizedName, /ETIQUETA|TAG|FITA/)) return 'raw_material';
|
||||
if (has(normalizedName, /\bCREDITO\b/)) return 'ignore_from_planning';
|
||||
if (has(normalizedName, /\bKIT\b/)) return 'kit_bundle';
|
||||
if (has(normalizedName, /TRANSPARENTE PP|SACO DE SEGURANCA|MILHEIRO|SACOLA|EMBALAGEM/)) return 'packaging';
|
||||
if (has(normalizedName, /PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA/)) return 'equipment';
|
||||
if (has(normalizedName, /FRETE|SERVICO|TRANSPORTE|MOTOTAXI|TECELAGEM|TINTURARIA|MAO DE OBRA/)) return 'service';
|
||||
if (has(normalizedName, /DUMPER|CABO FLAT|WIPPER|PRIMER|FLUIDO DE LIMPEZA|MISTURADOR|CABECA I3200|CABECA DE IMPRESSAO|SENSOR INFRAVERMELHO|CAPSULA FILTRO|PECA DE MAQUINA/)) return 'machine_part';
|
||||
if (has(normalizedName, /TINTA DTF|FILME DTF|POLIAMIDA.*DTF|DTF ROLO|PO PARA DTF|BOMBA DE TINTA/)) return 'dtf_input';
|
||||
if (has(normalizedName, /IMPRESSAO DTF|IMPRESSAO UV|CORRECAO IMPRESSAO|ESTAMPA|PERSONALIZACAO/)) return 'dtf_service';
|
||||
if (has(normalizedName, /MALHA|RIBANA|FIO|TECIDO|RESIDUO|RETALHO|PIMA|ALGODAO/)) {
|
||||
if (!has(normalizedName, /CAMISETA|MOLETOM|REGATA|BONE|CHINELO|OVERSIZE|OVER SIZE/)) {
|
||||
return 'raw_material';
|
||||
}
|
||||
}
|
||||
if (has(normalizedName, /BONE|TRUCKER|\bCAP\b|CHINELO/)) return 'finished_accessory';
|
||||
if (has(normalizedName, /CAMISETA|MOLETOM|CANGURU|REGATA|INFANTIL|OVER SIZE|OVERSIZE|PROMOCIONAL|VESTUARIO/)) return 'finished_apparel';
|
||||
if (has(normalizedName, /ETIQUETA|TAG|FITA|LINHA PARA COSTURA|COSTURA/)) return 'raw_material';
|
||||
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
export const getProductTypeConfig = (type: ProductTypeKey) => productTypeConfigs[type] || productTypeConfigs.unknown;
|
||||
|
||||
export const productTypeOptions = Object.values(productTypeConfigs)
|
||||
.filter(config => config.key !== 'ignore_from_planning')
|
||||
.map(config => ({ value: config.key, label: config.label }));
|
||||
|
||||
export const editableProductTypeOptions = Object.values(productTypeConfigs)
|
||||
.map(config => ({ value: config.key, label: config.label, description: config.description }));
|
||||
|
||||
export const resolveProductType = (
|
||||
name: string,
|
||||
override?: { productType?: ProductTypeKey | '' } | null
|
||||
): ProductTypeKey => {
|
||||
return override?.productType || classifyProductType(name);
|
||||
};
|
||||
|
||||
export const getDominantProductType = <T extends { productType: ProductTypeKey; quantitySold: number }>(rows: T[]) => {
|
||||
if (!rows.length) return 'unknown';
|
||||
|
||||
const totals = rows.reduce<Record<string, number>>((acc, row) => {
|
||||
acc[row.productType] = (acc[row.productType] || 0) + Math.max(row.quantitySold, 1);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (Object.entries(totals).sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown') as ProductTypeKey;
|
||||
};
|
||||
26
src/productColors.ts
Normal file
26
src/productColors.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
const COLOR_SWATCHES: Array<{ pattern: string; color: string }> = [
|
||||
{ pattern: 'preto', color: '#171717' },
|
||||
{ pattern: 'branco', color: '#f8fafc' },
|
||||
{ pattern: 'bege', color: '#d7bf9a' },
|
||||
{ pattern: 'cafe', color: '#79553d' },
|
||||
{ pattern: 'perola', color: '#e7dfcf' },
|
||||
{ pattern: 'marinho', color: '#172554' },
|
||||
{ pattern: 'bordo', color: '#6b1226' },
|
||||
{ pattern: 'verde', color: '#166534' },
|
||||
{ pattern: 'rosa', color: '#f0a6bf' },
|
||||
{ pattern: 'cinza', color: '#8f8f8f' },
|
||||
{ pattern: 'vermelho', color: '#b91c1c' },
|
||||
{ pattern: 'grafite', color: '#3f3f46' },
|
||||
{ pattern: 'azul', color: '#2563eb' },
|
||||
{ pattern: 'marron', color: '#6b4f3b' },
|
||||
{ pattern: 'marrom', color: '#6b4f3b' }
|
||||
];
|
||||
|
||||
const normalizeColorLabel = (label: string) => (
|
||||
label.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()
|
||||
);
|
||||
|
||||
export const getProductColor = (label: string) => {
|
||||
const normalizedLabel = normalizeColorLabel(label);
|
||||
return COLOR_SWATCHES.find(item => normalizedLabel.includes(item.pattern))?.color || '#64748b';
|
||||
};
|
||||
135
src/productParsing.ts
Normal file
135
src/productParsing.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
const sizeOrder = ['2', '4', '6', '8', '10', '12', '14', '16', 'PP', 'P', 'M', 'G', 'GG', 'XG', 'G1', 'G2', 'G3', 'G4', 'G5'];
|
||||
const sizeSet = new Set(sizeOrder);
|
||||
const knownColors = [
|
||||
'BRANCO + PRETO',
|
||||
'CINZA/PRETO',
|
||||
'VERDE BANDEIRA',
|
||||
'VERDE MILITAR',
|
||||
'AZUL MARINHO',
|
||||
'CINZA GRAFITE',
|
||||
'AMARELO',
|
||||
'BRANCO',
|
||||
'GRAFITE',
|
||||
'MARINHO',
|
||||
'VERMELHO',
|
||||
'BORDO',
|
||||
'CAFE',
|
||||
'CAQUI',
|
||||
'CHUMBO',
|
||||
'CINZA',
|
||||
'MARROM',
|
||||
'PEROLA',
|
||||
'PRETO',
|
||||
'ROYAL',
|
||||
'ROSA',
|
||||
'ROXO',
|
||||
'VERDE',
|
||||
'AZUL',
|
||||
'BEGE'
|
||||
].sort((a, b) => b.length - a.length);
|
||||
|
||||
export const normalizeProductText = (value: string) => value.replace(/\s+/g, ' ').trim();
|
||||
|
||||
const normalizeForMatch = (value: string) => normalizeProductText(value)
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toUpperCase();
|
||||
|
||||
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const toFlexiblePattern = (value: string) => escapeRegex(value).replace(/\s+/g, '\\s+');
|
||||
|
||||
const findLeadingColor = (value: string) => {
|
||||
const normalizedValue = normalizeForMatch(value);
|
||||
return knownColors.find(color => normalizedValue === color || normalizedValue.startsWith(`${color} `)) || '';
|
||||
};
|
||||
|
||||
const findTrailingColor = (value: string) => {
|
||||
const normalizedValue = normalizeForMatch(value);
|
||||
return knownColors.find(color => normalizedValue === color || normalizedValue.endsWith(` ${color}`) || normalizedValue.endsWith(`- ${color}`)) || '';
|
||||
};
|
||||
|
||||
export const encodeProductGroupKey = (value: string) => {
|
||||
const normalizedValue = normalizeProductText(value);
|
||||
const bytes = new TextEncoder().encode(normalizedValue);
|
||||
const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join('');
|
||||
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
};
|
||||
|
||||
export const decodeProductGroupKey = (value: string) => {
|
||||
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const paddedBase64 = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
|
||||
const binary = atob(paddedBase64);
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
|
||||
return normalizeProductText(new TextDecoder().decode(bytes));
|
||||
};
|
||||
|
||||
export const sortProductSizes = (sizes: string[]) => [...sizes].sort((a, b) => {
|
||||
const indexA = sizeOrder.indexOf(a);
|
||||
const indexB = sizeOrder.indexOf(b);
|
||||
if (indexA !== -1 || indexB !== -1) {
|
||||
return (indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA) - (indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB);
|
||||
}
|
||||
return a.localeCompare(b, 'pt-BR');
|
||||
});
|
||||
|
||||
export const parseProductName = (name: string) => {
|
||||
const cleanName = normalizeProductText(name);
|
||||
const explicitSizeMatch = cleanName.match(/\bTAMANHO\s*-?\s*([A-Z0-9]+)\b/i);
|
||||
const trailingTokenMatch = cleanName.match(/(?:\s+-\s+|\s)([A-Z0-9]+)$/i);
|
||||
const trailingToken = trailingTokenMatch?.[1]?.toUpperCase() || '';
|
||||
let size = (explicitSizeMatch?.[1] || (sizeSet.has(trailingToken) ? trailingToken : '')).toUpperCase();
|
||||
|
||||
const colorMatch = cleanName.match(/\bCOR\s+(.+?)(?:\s+TAMANHO|\s+-\s+[A-Z0-9]+$|$)/i);
|
||||
let color = normalizeProductText(findLeadingColor(colorMatch?.[1] || '') || colorMatch?.[1] || '');
|
||||
|
||||
if (!color && size) {
|
||||
const nameBeforeSize = cleanName.replace(new RegExp(`(?:\\s+-\\s+|\\s)${escapeRegex(size)}$`, 'i'), '');
|
||||
color = findTrailingColor(nameBeforeSize);
|
||||
}
|
||||
|
||||
if (!color) {
|
||||
const trailingColor = findTrailingColor(cleanName);
|
||||
if (trailingColor) {
|
||||
const normalizedName = normalizeForMatch(cleanName);
|
||||
const normalizedPrefix = normalizeProductText(normalizedName.replace(new RegExp(`${escapeRegex(trailingColor)}$`), ''));
|
||||
const tokenBeforeColor = normalizedPrefix.match(/([A-Z0-9]+)$/)?.[1] || '';
|
||||
if (sizeSet.has(tokenBeforeColor)) {
|
||||
color = trailingColor;
|
||||
size = tokenBeforeColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let baseName = cleanName
|
||||
.replace(/\bCOR\s+.+?(?:\s+TAMANHO\s*-?\s*[A-Z0-9]+|\s+-\s+[A-Z0-9]+$|$)/i, '')
|
||||
.replace(/\bTAMANHO\s*-?\s*[A-Z0-9]+\b/i, '')
|
||||
.replace(/\s+-\s*[A-Z0-9]+$/i, '');
|
||||
|
||||
if (color && size) {
|
||||
const colorPattern = toFlexiblePattern(color);
|
||||
const sizePattern = escapeRegex(size);
|
||||
baseName = baseName
|
||||
.replace(new RegExp(`\\s+-\\s*${colorPattern}\\s+${sizePattern}$`, 'i'), '')
|
||||
.replace(new RegExp(`\\s+${colorPattern}\\s+-\\s*${sizePattern}$`, 'i'), '')
|
||||
.replace(new RegExp(`\\s+${sizePattern}\\s+${colorPattern}$`, 'i'), '')
|
||||
.replace(new RegExp(`\\s+${colorPattern}\\s+${sizePattern}$`, 'i'), '');
|
||||
}
|
||||
|
||||
if (color) {
|
||||
const colorPattern = toFlexiblePattern(color);
|
||||
baseName = baseName
|
||||
.replace(new RegExp(`\\s+-\\s*${colorPattern}$`, 'i'), '')
|
||||
.replace(new RegExp(`\\s+${colorPattern}$`, 'i'), '');
|
||||
}
|
||||
|
||||
baseName = normalizeProductText(baseName.replace(/\s+-\s*$/g, ''));
|
||||
|
||||
return {
|
||||
baseName: baseName || cleanName,
|
||||
color,
|
||||
size
|
||||
};
|
||||
};
|
||||
506
src/types.ts
506
src/types.ts
@@ -1,3 +1,5 @@
|
||||
import type { ProductTypeKey } from './productClassification';
|
||||
|
||||
export interface OrderData {
|
||||
Nome_Cliente: string;
|
||||
Data_Pedido: string;
|
||||
@@ -9,6 +11,12 @@ export interface OrderData {
|
||||
Recebido_Em?: string;
|
||||
ID_Pedido?: string;
|
||||
Fone_Cliente?: string;
|
||||
cliente_nome_fantasia?: string;
|
||||
id_vendedor?: string;
|
||||
nome_vendedor?: string;
|
||||
marketplace?: string;
|
||||
canal_venda?: string;
|
||||
numero_ecommerce?: string;
|
||||
}
|
||||
|
||||
export interface StockData {
|
||||
@@ -19,6 +27,341 @@ export interface StockData {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type ProductionOrderStatus = 'open' | 'in_progress' | 'finished' | 'canceled' | string;
|
||||
|
||||
export interface ProductionOrderMarker {
|
||||
label: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface ProductionOrderComponent {
|
||||
id: number;
|
||||
componentTinyId: string;
|
||||
componentSku: string;
|
||||
componentName: string;
|
||||
quantityPerUnit: number;
|
||||
totalQuantity: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface ProductionOrderStep {
|
||||
id: number;
|
||||
stepNumber: number | null;
|
||||
name: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
status: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ProductionOrderItem {
|
||||
id: number;
|
||||
tinyId: string;
|
||||
number: string;
|
||||
status: ProductionOrderStatus;
|
||||
statusLabel: string;
|
||||
orderReference: string;
|
||||
issueDate: string | null;
|
||||
expectedDate: string | null;
|
||||
productSku: string;
|
||||
productDescription: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
integrationStatus: string;
|
||||
notes: string;
|
||||
supplier: string;
|
||||
lotCode: string;
|
||||
rollQuantity: number | null;
|
||||
fabricKg: number | null;
|
||||
ribKg: number | null;
|
||||
yieldPiecesPerKg: number | null;
|
||||
markers: ProductionOrderMarker[];
|
||||
components: ProductionOrderComponent[];
|
||||
steps: ProductionOrderStep[];
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ProductionOrderCounts {
|
||||
all: number;
|
||||
open: number;
|
||||
in_progress: number;
|
||||
finished: number;
|
||||
canceled: number;
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
export interface ProductionOrderSummary {
|
||||
orders: ProductionOrderItem[];
|
||||
counts: ProductionOrderCounts;
|
||||
}
|
||||
|
||||
export type ProductionOrderMarkerPayload = {
|
||||
label: string;
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
export type ProductionOrderPayload = {
|
||||
number?: string;
|
||||
status?: ProductionOrderStatus;
|
||||
orderReference?: string;
|
||||
issueDate?: string | null;
|
||||
expectedDate?: string | null;
|
||||
productSku?: string;
|
||||
productDescription: string;
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
integrationStatus?: string;
|
||||
markers?: ProductionOrderMarkerPayload[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface CreateProductionOrdersResult {
|
||||
created: ProductionOrderItem[];
|
||||
skipped: Array<{
|
||||
productSku?: string;
|
||||
productDescription?: string;
|
||||
reason: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS';
|
||||
|
||||
export interface CutProductOverride {
|
||||
familyKey?: CutFamilyKey | '';
|
||||
color?: string;
|
||||
size?: string;
|
||||
productType?: ProductTypeKey | '';
|
||||
planningNotes?: string;
|
||||
}
|
||||
|
||||
export interface CuttingSettings {
|
||||
familyYields: Partial<Record<CutFamilyKey, number>>;
|
||||
productOverrides: Record<string, CutProductOverride>;
|
||||
}
|
||||
|
||||
export type CatalogProductType = 'finished_product' | 'raw_material';
|
||||
|
||||
export interface CatalogCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CatalogProduct {
|
||||
id: number;
|
||||
type: CatalogProductType;
|
||||
sku: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
categoryName: string;
|
||||
composition: string;
|
||||
notes: string;
|
||||
gramature: number | null;
|
||||
materialYield: number | null;
|
||||
widthCm: number | null;
|
||||
color: string;
|
||||
subcategory: string;
|
||||
sizes: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ConsumptionReference {
|
||||
id: number;
|
||||
productId: number;
|
||||
productSku: string;
|
||||
productName: string;
|
||||
materialProductId: number | null;
|
||||
materialSku: string;
|
||||
materialName: string;
|
||||
color: string;
|
||||
generalYield: number | null;
|
||||
sizeYields: Record<string, number>;
|
||||
sizeAreas: Record<string, number>;
|
||||
gramature: number | null;
|
||||
efficiencyPercent: number | null;
|
||||
ribGPerPiece: number | null;
|
||||
materialCostPerKg: number | null;
|
||||
consumptionQuantity: number | null;
|
||||
consumptionUnit: string;
|
||||
source: string;
|
||||
lastProductionOrderId: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CatalogSummary {
|
||||
categories: CatalogCategory[];
|
||||
products: CatalogProduct[];
|
||||
consumptionReferences: ConsumptionReference[];
|
||||
}
|
||||
|
||||
export type CatalogCategoryPayload = {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type CatalogProductPayload = {
|
||||
type: CatalogProductType;
|
||||
sku: string;
|
||||
name: string;
|
||||
categoryId?: number | null;
|
||||
composition?: string;
|
||||
notes?: string;
|
||||
gramature?: number | string | null;
|
||||
materialYield?: number | string | null;
|
||||
widthCm?: number | string | null;
|
||||
color?: string;
|
||||
subcategory?: string;
|
||||
sizes?: string[];
|
||||
};
|
||||
|
||||
export type ConsumptionReferencePayload = {
|
||||
productId: number | string;
|
||||
materialProductId?: number | string | null;
|
||||
color?: string;
|
||||
generalYield?: number | string | null;
|
||||
sizeYields?: Record<string, number | string>;
|
||||
sizeAreas?: Record<string, number | string>;
|
||||
gramature?: number | string | null;
|
||||
efficiencyPercent?: number | string | null;
|
||||
ribGPerPiece?: number | string | null;
|
||||
materialCostPerKg?: number | string | null;
|
||||
consumptionQuantity?: number | string | null;
|
||||
consumptionUnit?: string | null;
|
||||
};
|
||||
|
||||
export type SupplyReceiptStatus = 'pending' | 'approved';
|
||||
|
||||
export interface SupplyReceipt {
|
||||
id: number;
|
||||
category: string;
|
||||
product: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
supplier: string;
|
||||
invoice: string;
|
||||
notes: string;
|
||||
status: SupplyReceiptStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
approvedAt: string | null;
|
||||
}
|
||||
|
||||
export interface SupplyLot {
|
||||
id: number;
|
||||
receiptId: number | null;
|
||||
category: string;
|
||||
product: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
supplier: string;
|
||||
invoice: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SupplyMovement {
|
||||
id: number;
|
||||
receiptId: number | null;
|
||||
lotId: number | null;
|
||||
type: string;
|
||||
category: string;
|
||||
product: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
reason: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SupplyFabricPlan {
|
||||
id: number;
|
||||
material: string;
|
||||
color: string;
|
||||
quantityKg: number;
|
||||
supplier: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SupplyPurchaseNeed {
|
||||
material: string;
|
||||
plannedKg: number;
|
||||
stockKg: number;
|
||||
pendingKg: number;
|
||||
purchaseKg: number;
|
||||
priority: string;
|
||||
status: 'critical' | 'attention' | 'ok';
|
||||
suppliers: string[];
|
||||
colors: string[];
|
||||
unit?: string;
|
||||
source?: string;
|
||||
missingReference?: boolean;
|
||||
products?: Array<{
|
||||
productId: string;
|
||||
name: string;
|
||||
suggestedQuantity: number;
|
||||
quantitySold: number;
|
||||
stockQuantity: number;
|
||||
yieldPerKg?: number;
|
||||
consumptionQuantity?: number;
|
||||
consumptionUnit?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SupplyStats {
|
||||
totalQuantityKg: number;
|
||||
activeLots: number;
|
||||
rolls: number;
|
||||
alerts: number;
|
||||
pendingReceipts: number;
|
||||
approvedReceipts: number;
|
||||
}
|
||||
|
||||
export interface SupplySummary {
|
||||
receipts: SupplyReceipt[];
|
||||
lots: SupplyLot[];
|
||||
movements: SupplyMovement[];
|
||||
fabricPlans: SupplyFabricPlan[];
|
||||
purchaseNeeds: SupplyPurchaseNeed[];
|
||||
stats: SupplyStats;
|
||||
}
|
||||
|
||||
export type SupplyReceiptPayload = {
|
||||
category: string;
|
||||
product: string;
|
||||
quantity: number | string;
|
||||
unit: string;
|
||||
supplier?: string;
|
||||
invoice?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type SupplyFabricPlanPayload = {
|
||||
material: string;
|
||||
color?: string;
|
||||
quantityKg: number | string;
|
||||
supplier?: string;
|
||||
priority?: string;
|
||||
};
|
||||
|
||||
export type SupplyInventoryAdjustmentPayload = {
|
||||
countedQuantity: number | string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type SupplyProductionExitPayload = {
|
||||
quantity: number | string;
|
||||
productionOrderNumber?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
@@ -50,6 +393,7 @@ export interface CreateUserResult {
|
||||
export interface DashboardAnalytics {
|
||||
totalRevenue: number;
|
||||
totalOrders: number;
|
||||
orderLineCount?: number;
|
||||
averageOrderValue: number;
|
||||
salesByProduct: Array<{
|
||||
name: string;
|
||||
@@ -61,10 +405,102 @@ export interface DashboardAnalytics {
|
||||
id: string;
|
||||
value: number;
|
||||
}>;
|
||||
revenueBySeller?: Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
value: number;
|
||||
}>;
|
||||
ordersBySeller?: Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
value: number;
|
||||
}>;
|
||||
sellerRevenueByDate?: Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
date: string;
|
||||
value: number;
|
||||
orders?: number;
|
||||
}>;
|
||||
sellerRevenueByHour?: Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
hour: number;
|
||||
value: number;
|
||||
orders?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductAnalyticsItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantitySold: number;
|
||||
revenue: number;
|
||||
orderLineCount: number;
|
||||
lastPrice: number;
|
||||
stock: number;
|
||||
firstSaleDate: string | null;
|
||||
lastSaleDate: string | null;
|
||||
}
|
||||
|
||||
export interface ProductDetailsAnalytics {
|
||||
range: {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
};
|
||||
productInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
};
|
||||
chartData: Array<{
|
||||
date: string;
|
||||
value: number;
|
||||
quantitySold?: number;
|
||||
revenue?: number;
|
||||
orderCount?: number;
|
||||
averageTicket?: number;
|
||||
}>;
|
||||
totalSold: number;
|
||||
totalRevenue: number;
|
||||
totalOrders?: number;
|
||||
averageTicket?: number;
|
||||
variantBreakdown?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
quantitySold: number;
|
||||
revenue: number;
|
||||
orderCount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductCompositionComponent {
|
||||
id: number;
|
||||
componentTinyId: string;
|
||||
componentSku: string;
|
||||
componentName: string;
|
||||
quantityPerUnit: number;
|
||||
unit: string;
|
||||
productId: string | null;
|
||||
}
|
||||
|
||||
export interface ProductComposition {
|
||||
id: number;
|
||||
source: string;
|
||||
externalSourceId: string;
|
||||
finishedProductSku: string;
|
||||
finishedProductDescription: string;
|
||||
finishedProductUnit: string;
|
||||
finishedTinyProductId: string;
|
||||
sourceMetadata: Record<string, unknown>;
|
||||
lastSyncedAt: string | null;
|
||||
components: ProductCompositionComponent[];
|
||||
}
|
||||
|
||||
export interface ClientAnalyticsItem {
|
||||
customerKey: string;
|
||||
clientToken: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
quantityPurchased: number;
|
||||
@@ -73,8 +509,40 @@ export interface ClientAnalyticsItem {
|
||||
lastPurchaseDate: string;
|
||||
}
|
||||
|
||||
export interface ClientMetadataFilters {
|
||||
marketplace: string;
|
||||
canal_venda: string;
|
||||
seller: string;
|
||||
}
|
||||
|
||||
export interface ClientSellerFilterOption {
|
||||
value: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ClientFilterOptions {
|
||||
marketplaces: string[];
|
||||
salesChannels: string[];
|
||||
sellers: ClientSellerFilterOption[];
|
||||
}
|
||||
|
||||
export interface ClientPurchasePatternAnalytics {
|
||||
weekdayRangeLabel?: string;
|
||||
hourRangeLabel?: string;
|
||||
purchaseWeekdays: Array<{
|
||||
label: string;
|
||||
value: number;
|
||||
}>;
|
||||
purchaseHours: Array<{
|
||||
label: string;
|
||||
value: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RfmClient {
|
||||
customerKey: string;
|
||||
clientToken: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
monetary: number;
|
||||
@@ -112,6 +580,44 @@ export interface RfmAnalytics {
|
||||
};
|
||||
}
|
||||
|
||||
export interface GroupedClientOrder {
|
||||
date: string;
|
||||
orderId: string;
|
||||
orderTotal: number;
|
||||
items: OrderData[];
|
||||
}
|
||||
|
||||
export interface ClientDetailsAnalytics {
|
||||
range: {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
};
|
||||
clientToken: string;
|
||||
chartData: Array<{
|
||||
date: string;
|
||||
value: number;
|
||||
}>;
|
||||
purchaseWeekdays?: Array<{
|
||||
label: string;
|
||||
value: number;
|
||||
}>;
|
||||
purchaseHours?: Array<{
|
||||
label: string;
|
||||
value: number;
|
||||
}>;
|
||||
purchaseWeekdayRangeLabel?: string;
|
||||
purchaseHourRangeLabel?: string;
|
||||
groupedOrders: GroupedClientOrder[];
|
||||
allTimeOrderCount: number;
|
||||
clientName: string;
|
||||
clientPhone: string;
|
||||
hasClient: boolean;
|
||||
periodAverageTicket: number;
|
||||
periodOrderCount: number;
|
||||
periodSpent: number;
|
||||
periodItems: number;
|
||||
}
|
||||
|
||||
export type CampaignStatus = 'pending' | 'processing' | 'sent' | 'failed' | 'skipped';
|
||||
|
||||
export interface CampaignQueueItem {
|
||||
|
||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export default defineConfig(({ command }) => ({
|
||||
tailwindcss()
|
||||
],
|
||||
server: {
|
||||
port: 3001,
|
||||
port: 3002,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3004',
|
||||
@@ -34,7 +34,7 @@ export default defineConfig(({ command }) => ({
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
port: 3002,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3004',
|
||||
|
||||
Reference in New Issue
Block a user