Compare commits

...

146 Commits

Author SHA1 Message Date
Cauê Faleiros
b431802d50 Add product composition export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s
2026-07-31 10:30:49 -03:00
Cauê Faleiros
5559e9951a Support Tiny Olist V3 product compositions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m57s
2026-07-29 15:28:00 -03:00
Cauê Faleiros
9c7a383ee2 Fix duplicate top campaign clients by phone
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m22s
2026-07-29 10:44:22 -03:00
Cauê Faleiros
496f9d432d Deduplicate top campaign clients by name
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m54s
2026-07-29 09:54:22 -03:00
Cauê Faleiros
9a7e0d6818 Normalize Turnstile env values
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
2026-07-28 13:37:37 -03:00
Cauê Faleiros
6afd0c0392 Guard invalid Turnstile site key
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-07-28 13:24:48 -03:00
Cauê Faleiros
c741e520ea Support Portainer Turnstile env aliases
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-07-28 13:10:13 -03:00
Cauê Faleiros
ed3a3f0571 Load Turnstile site key at runtime
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
2026-07-28 13:03:51 -03:00
Cauê Faleiros
20dde11ff0 Improve Turnstile login failure message
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
2026-07-28 12:56:17 -03:00
Cauê Faleiros
4857438e6f Reapply "Use Cloudflare Turnstile secret env name"
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m35s
This reverts commit 302b55b346.
2026-07-28 12:42:27 -03:00
Cauê Faleiros
252c2447a8 Reapply "Add Turnstile captcha to login"
This reverts commit a7c732e139.
2026-07-28 12:42:27 -03:00
Cauê Faleiros
a7c732e139 Revert "Add Turnstile captcha to login"
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
This reverts commit 5aa1be27bb.
2026-07-28 11:52:46 -03:00
Cauê Faleiros
302b55b346 Revert "Use Cloudflare Turnstile secret env name"
This reverts commit e8eafaf9b9.
2026-07-28 11:52:46 -03:00
Cauê Faleiros
e8eafaf9b9 Use Cloudflare Turnstile secret env name
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
2026-07-28 11:46:26 -03:00
Cauê Faleiros
5aa1be27bb Add Turnstile captcha to login
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m57s
2026-07-28 11:34:47 -03:00
Cauê Faleiros
5c9ab6bb11 Add top campaign clients endpoint
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m39s
2026-07-27 14:39:44 -03:00
Cauê Faleiros
800eb976ab Derive consumption references from Tiny OP sync
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m16s
2026-07-23 11:53:18 -03:00
Cauê Faleiros
c07938c94e Add Tiny production order detail sync 2026-07-23 10:42:53 -03:00
Cauê Faleiros
615e07ad32 Allow DTF in campaigns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m40s
2026-07-22 11:27:15 -03:00
Cauê Faleiros
c07264fd69 Filter campaign products to apparel 2026-07-22 11:22:49 -03:00
Cauê Faleiros
9c77475ce5 Seed default catalog categories 2026-07-22 09:21:21 -03:00
Cauê Faleiros
eaa0a5010d Clarify data cleanup ownership
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-07-21 15:02:30 -03:00
Cauê Faleiros
a6e07e46d9 Align purchase needs pending column
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-07-21 14:22:17 -03:00
Cauê Faleiros
166f97c629 Separate purchase needs status styling 2026-07-21 14:16:17 -03:00
Cauê Faleiros
98d278947b Remove purchase needs warning copy 2026-07-21 14:10:22 -03:00
Cauê Faleiros
c7536d9097 Tighten purchase needs setup table 2026-07-21 14:02:12 -03:00
Cauê Faleiros
b4e51eb8eb Refine purchase needs setup queue 2026-07-21 13:56:10 -03:00
Cauê Faleiros
51b73ed82b Add database diagnostic export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
2026-07-21 13:20:48 -03:00
Cauê Faleiros
eb5cc01d56 Paginate stock inventory tab
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-07-21 11:30:00 -03:00
Cauê Faleiros
4c3df482be Match fabric planning card heights 2026-07-21 11:15:41 -03:00
Cauê Faleiros
377754c26e Move fabric plan form to right side 2026-07-21 11:02:29 -03:00
Cauê Faleiros
bc74a1089b Show Tiny stock in inventory control
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m14s
2026-07-21 10:46:24 -03:00
Cauê Faleiros
f992517891 Reposition fabric planning form 2026-07-21 10:11:23 -03:00
Cauê Faleiros
6283f86091 Simplify cutting page workflow views
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m44s
2026-07-20 15:16:03 -03:00
Cauê Faleiros
e1f001b57d Add production order generation preview
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
2026-07-20 14:12:55 -03:00
Cauê Faleiros
88089343a8 Add local production order workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
2026-07-20 12:57:28 -03:00
Cauê Faleiros
9af5f296a7 Improve planning data normalization
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-07-20 11:24:47 -03:00
Cauê Faleiros
3066ba06a2 Refine planning product classification
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m15s
2026-07-20 10:16:58 -03:00
Cauê Faleiros
6608957574 Separate accessory replenishment queue
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-07-17 11:02:20 -03:00
Cauê Faleiros
1dcc565299 Standardize operations page layout 2026-07-17 10:10:59 -03:00
Cauê Faleiros
0cae89277f Simplify pending issue labels
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m12s
2026-07-17 09:27:08 -03:00
Cauê Faleiros
e332374b2d Polish supply planning copy 2026-07-17 09:21:58 -03:00
Cauê Faleiros
e62ec6052e Add current data supply planning
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m34s
2026-07-16 14:34:07 -03:00
Cauê Faleiros
4bfd0d4591 Improve users and purchase needs UI
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 35s
2026-07-16 12:56:11 -03:00
Cauê Faleiros
897d4cbe66 Add planning issues work queue
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m10s
2026-07-16 12:53:17 -03:00
Cauê Faleiros
8eeadf00ae Add manual SKU planning overrides
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
2026-07-16 11:48:52 -03:00
Cauê Faleiros
422828f343 Add inferred product classification
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m36s
2026-07-16 10:15:24 -03:00
Cauê Faleiros
5e81a8f2c3 Update project data flow context
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m35s
2026-07-15 10:01:54 -03:00
Cauê Faleiros
f92348958b Make dashboard trend line optional
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
2026-07-14 14:51:07 -03:00
Cauê Faleiros
ba9cc3ad01 Fix large sold quantity icon layout 2026-07-14 14:23:28 -03:00
Cauê Faleiros
948931886e Improve chart readability and drilldowns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
2026-07-14 13:55:59 -03:00
Cauê Faleiros
63efb47e77 Route SKU actions to focused editors 2026-07-14 13:24:25 -03:00
Cauê Faleiros
bc05fb4504 Add SKU edit actions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m18s
2026-07-14 10:18:04 -03:00
Cauê Faleiros
060b4da907 Connect supplies to project demand 2026-07-13 15:17:33 -03:00
Cauê Faleiros
8f3f2f3e93 Connect production orders to stock movements 2026-07-13 13:22:11 -03:00
Cauê Faleiros
afaa18ca14 Clarify purchase coverage status
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 38s
2026-07-13 12:46:43 -03:00
Cauê Faleiros
5b6a362bbd Connect supply planning to purchase needs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
2026-07-13 12:06:50 -03:00
Cauê Faleiros
8320f2ae35 Persist supply receipts and stock
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
2026-07-13 11:31:41 -03:00
Cauê Faleiros
1bf5e518de Remove dashboard operational copilot 2026-07-13 11:16:07 -03:00
Cauê Faleiros
d3ea2048cb Add dashboard operational copilot 2026-07-13 11:11:10 -03:00
Cauê Faleiros
c68d3c7ed4 Make supply receipts interactive
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m54s
2026-07-13 10:58:39 -03:00
Cauê Faleiros
2b1bb8eda9 Restore fabric planning in supplies 2026-07-13 10:45:50 -03:00
Cauê Faleiros
4d659d5eb6 Remove unfinished supplies placeholders 2026-07-13 10:30:45 -03:00
Cauê Faleiros
fcb7eaf51d Shorten supplies breadcrumb labels 2026-07-13 10:25:41 -03:00
Cauê Faleiros
be1924bc94 Fix supplies navigation paths 2026-07-13 10:21:48 -03:00
Cauê Faleiros
3928717293 Add supplies module shell 2026-07-13 10:02:38 -03:00
Cauê Faleiros
3bea89a8ac Add collapsed sidebar section dividers 2026-07-13 09:38:07 -03:00
Cauê Faleiros
c7808702fa Add catalog registrations workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
2026-07-09 15:12:27 -03:00
Cauê Faleiros
d3d8886a90 Format product color labels 2026-07-09 13:58:48 -03:00
Cauê Faleiros
634cf4db48 Simplify cutting controls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m45s
2026-07-09 13:23:25 -03:00
Cauê Faleiros
423442578f Clarify cutting blockers and group replenishment context 2026-07-09 11:16:10 -03:00
Cauê Faleiros
34a75e64f6 Link replenishment groups to group details 2026-07-08 14:43:18 -03:00
Cauê Faleiros
0b883dd8c2 Unify product color badges 2026-07-08 14:34:53 -03:00
Cauê Faleiros
7668b83de6 Restore pointer cursor on back buttons 2026-07-08 13:57:40 -03:00
Cauê Faleiros
090fa21ba5 Fix cutting settings overlay coverage
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-07-08 13:49:33 -03:00
Cauê Faleiros
2ff426559a Improve light theme accent contrast 2026-07-08 13:45:18 -03:00
Cauê Faleiros
248acee2b8 Refine cutting dashboard layout 2026-07-08 13:39:47 -03:00
Cauê Faleiros
d3ddc17381 Clean up cutting settings UI
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 33s
2026-07-08 12:39:41 -03:00
Cauê Faleiros
c736dbe873 Improve cutting correction workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m46s
2026-07-08 11:11:02 -03:00
Cauê Faleiros
2dcef87229 Persist cutting rules in database 2026-07-08 09:32:28 -03:00
Cauê Faleiros
d512f8b00d Add editable cutting rules 2026-07-07 15:20:57 -03:00
Cauê Faleiros
d1220fdd3f Add OP-aware cutting planning 2026-07-07 15:11:42 -03:00
Cauê Faleiros
f451c3df63 Add native cutting plan page 2026-07-07 15:03:24 -03:00
Cauê Faleiros
fedb1c88a8 Add cutting workbook normalizer 2026-07-07 14:41:35 -03:00
Cauê Faleiros
c881dd7dde Separate replenishment coverage target
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
2026-07-07 14:29:21 -03:00
Cauê Faleiros
18d2b6e407 Fix replenishment need filter count 2026-07-07 13:21:02 -03:00
Cauê Faleiros
5a5b9a035f Align products controls with replenishment layout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m8s
2026-07-07 12:37:00 -03:00
Cauê Faleiros
ae7ac964f7 Polish product group details layout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
2026-07-07 10:43:42 -03:00
Cauê Faleiros
566b032fb3 Add product group analysis view
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
2026-07-07 10:34:17 -03:00
Cauê Faleiros
022ed007ff Make detail back buttons history-aware 2026-07-07 10:23:04 -03:00
Cauê Faleiros
f2e14bb063 Simplify replenishment date controls 2026-07-07 10:16:11 -03:00
Cauê Faleiros
c5cc682509 Differentiate replenishment navigation icon 2026-07-07 10:10:52 -03:00
Cauê Faleiros
56cff262ab Add grouped replenishment analysis
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m1s
2026-07-07 09:59:07 -03:00
Cauê Faleiros
e0c71de3eb Add replenishment needs view 2026-07-07 09:48:39 -03:00
Cauê Faleiros
76de11f4de Use stock status labels for products
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
2026-07-06 14:01:05 -03:00
Cauê Faleiros
578835d9da Close product filters with escape 2026-07-06 13:50:40 -03:00
Cauê Faleiros
dae0b227c1 Prevent product action button wrapping
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
2026-07-06 13:40:02 -03:00
Cauê Faleiros
a73b776020 Clarify product coverage labels
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
2026-07-06 13:35:01 -03:00
Cauê Faleiros
67116d13c4 Polish product stock table layout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
2026-07-06 13:23:07 -03:00
Cauê Faleiros
8a9f47f569 Merge stock insights into products
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m58s
2026-07-06 13:08:41 -03:00
Cauê Faleiros
b53ca39fe9 Require apply for custom date range
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m34s
2026-07-03 15:23:34 -03:00
Cauê Faleiros
caea53b94d Add production orders page
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
2026-07-02 10:07:13 -03:00
Cauê Faleiros
1b5a8556ba Restore seller zero in dashboard
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m0s
2026-07-02 09:19:19 -03:00
Cauê Faleiros
67357b591d Highlight client purchase patterns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
2026-07-01 15:25:20 -03:00
Cauê Faleiros
c738b804af Apply recent hour pattern to client details
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m36s
2026-07-01 15:05:47 -03:00
Cauê Faleiros
27d2980b32 Use recent data for purchase hour pattern
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
2026-07-01 13:17:05 -03:00
Cauê Faleiros
57fd48ccc5 Remove pagination input spinner
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
2026-07-01 11:36:30 -03:00
Cauê Faleiros
e2b2163c83 Add full pagination controls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
2026-07-01 11:26:09 -03:00
Cauê Faleiros
f5e2de9a35 Improve product analytics UI
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m48s
2026-07-01 10:57:33 -03:00
Cauê Faleiros
a26bc8813e Add client purchase pattern analytics
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
2026-07-01 10:25:07 -03:00
Cauê Faleiros
b99b60b32e Hide non-seller entries from seller chart
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m50s
2026-07-01 09:46:53 -03:00
Cauê Faleiros
ae7736019f Add hourly detail charts for single-day ranges
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-06-30 14:51:44 -03:00
Cauê Faleiros
6a414a5bf4 Add hourly seller chart for single-day ranges
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m35s
2026-06-30 14:30:14 -03:00
Cauê Faleiros
839e1f814e Show RFV loading state in clients table
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m30s
2026-06-30 12:49:39 -03:00
Cauê Faleiros
1e511f35a9 Constrain campaign preview list
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m38s
2026-06-30 10:36:22 -03:00
Cauê Faleiros
d66aa77f4d Polish campaigns page UI
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m17s
2026-06-29 11:34:29 -03:00
Cauê Faleiros
fc6f417cc6 Refine seller chart legend
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
2026-06-29 10:58:18 -03:00
Cauê Faleiros
9bd595fcac Add stale refresh indicator
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
2026-06-29 10:46:03 -03:00
Cauê Faleiros
ff55d0618e Add skeleton loaders to client and product pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m16s
2026-06-29 09:55:19 -03:00
Cauê Faleiros
a0a1108f3a Use all-time client purchase patterns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m25s
2026-06-29 09:29:16 -03:00
Cauê Faleiros
8b3ac60883 Add client purchase pattern charts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
2026-06-26 15:28:33 -03:00
Cauê Faleiros
b08bdc4f41 Collapse client detail orders
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-06-26 15:13:01 -03:00
Cauê Faleiros
285587bda5 Animate seller dashboard chart
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
2026-06-26 15:02:16 -03:00
Cauê Faleiros
901752ebc5 Refine seller dashboard chart
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
2026-06-26 14:53:49 -03:00
Cauê Faleiros
59fffa2018 Use area charts on detail pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
2026-06-26 12:40:43 -03:00
Cauê Faleiros
8c788b66ac Add dashboard and RFV skeleton loaders
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
2026-06-26 11:41:39 -03:00
Cauê Faleiros
3d25bc5179 Add off-white theme mode
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m31s
2026-06-26 11:11:36 -03:00
Cauê Faleiros
789a9304a0 Remove RFV outside click clear
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m22s
2026-06-26 09:58:29 -03:00
Cauê Faleiros
af01eba4a7 Sync client segment badge colors
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
2026-06-25 13:19:26 -03:00
Cauê Faleiros
eb77dd3ba2 Refine dashboard and RFV visuals
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
2026-06-25 10:38:31 -03:00
Cauê Faleiros
a6858fe2fc Update project context for recent analytics changes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m19s
2026-06-25 09:11:19 -03:00
Cauê Faleiros
4131bf9eb8 Update RFV lifecycle to monthly windows
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
2026-06-24 13:37:11 -03:00
Cauê Faleiros
ef27cff046 Add seller performance dashboard charts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 35s
2026-06-24 13:24:28 -03:00
Cauê Faleiros
ca9615a1fd Clean seller metadata display names
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
2026-06-24 13:03:55 -03:00
Cauê Faleiros
b129307bae Fix seller filter options query
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m13s
2026-06-24 12:47:27 -03:00
Cauê Faleiros
b40f6fa23f Restore seller dropdown filter
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-06-24 11:42:30 -03:00
Cauê Faleiros
aeb801c0ba Allow searching sellers in client filters
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m5s
2026-06-24 11:18:27 -03:00
Cauê Faleiros
b7a2059070 Show all client metadata filter options
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
2026-06-23 15:06:41 -03:00
Cauê Faleiros
06faadab5f Refresh client filter options reliably
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
2026-06-23 14:56:36 -03:00
Cauê Faleiros
7d60073757 Smooth analytics page reloads
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 34s
2026-06-23 14:45:21 -03:00
Cauê Faleiros
335d231903 Fix client identity when phone is added
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
2026-06-23 14:23:28 -03:00
Cauê Faleiros
3524405712 New filter panel layout at client page
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
2026-06-23 13:46:55 -03:00
Cauê Faleiros
62e0239ee4 Optimize product detail analytics loading
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
2026-06-22 15:49:40 -03:00
Cauê Faleiros
3cd4bfc426 Optimize products list analytics loading
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
2026-06-22 15:30:54 -03:00
Cauê Faleiros
bd98348989 Preserve existing order metadata on empty upserts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m21s
2026-06-22 15:15:44 -03:00
Cauê Faleiros
3568e48ce9 Add Tiny ERP order metadata ingestion 2026-06-22 15:02:38 -03:00
76 changed files with 20539 additions and 1176 deletions

View File

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

View File

@@ -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. * **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. * **Backend Analytics Foundation:** Raw `/api/data` still exists for legacy pages, but dashboard/product/client aggregate endpoints now exist under `/api/analytics/*`. Dashboard already uses the backend dashboard aggregation endpoint so it does not wait on the full raw order download.
* **Route Code Splitting:** Frontend routes are lazy-loaded with `React.lazy`/`Suspense` so the first JS payload stays smaller. Recharts-heavy page chunks are loaded on demand. * **Route Code Splitting:** Frontend routes are lazy-loaded with `React.lazy`/`Suspense` so the first JS payload stays smaller. Recharts-heavy page chunks are loaded on demand.
* **Analytics Client Cache:** Frontend analytics requests use a small shared cache/deduping layer in `src/dataService.ts`. Navigating between Dashboard, RFV, Products, and Clients should reuse fresh responses instead of refetching every route mount unless query params change or the cache expires.
* **Tiny as Current Source, Graphs as Future Operating Layer:** Tiny ERP is the current source for sales, product/order metadata, and stock. Graphs should increasingly become the place where the business manages production intelligence: cut configuration, SKU families, raw material relationships, consumption references, and operational charts. `NECESSIDADE DE CORTE.xlsx` is reference material only and must not become a runtime dependency.
## 4. Directory Structure ## 4. Directory Structure
```text ```text
@@ -63,7 +65,9 @@ This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a rea
``` ```
## 5. Core Business Rules & Domain Entities ## 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. * **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. * **Campaign Queue Entity (`stock_campaign_queue` table):** Tracks queued product restock deltas by `base_product_name`, product ID/name, delta, status (`pending`, `processing`, `sent`, `failed`, `skipped`), attempts, errors, and timestamps.
* **WhatsApp Campaign Payload:** The backend sends one n8n webhook payload per scheduled campaign run. The payload includes `baseProduct`, `productsText`, `total_delta`, `sizes`, `products`, and `customers`. * **WhatsApp Campaign Payload:** The backend sends one n8n webhook payload per scheduled campaign run. The payload includes `baseProduct`, `productsText`, `total_delta`, `sizes`, `products`, and `customers`.
@@ -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`. * **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. * **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. * **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. * **Gitea Actions:** A workflow located in `.gitea/workflows/deploy.yml` triggers on pushes to the `main` branch.
* **Docker Registry:** The pipeline builds the `frontend` and `backend` Docker images and pushes them directly to `gitea.blyzer.com.br/blyzer/`. * **Docker Registry:** The pipeline builds the `frontend` and `backend` Docker images and pushes them directly to `gitea.blyzer.com.br/blyzer/`.
* **Production Deployment:** Updates are deployed manually via Portainer by pulling the `latest` image tags from the Gitea registry and redeploying the stack. * **Production Deployment:** Updates are deployed manually via Portainer by pulling the `latest` image tags from the Gitea registry and redeploying the stack.
* **Environment Variables:** Security secrets (`API_KEY`, `JWT_SECRET`, `POSTGRES_PASSWORD`, `N8N_WHATSAPP_TRIGGER_URL`) are injected via the Portainer stack configuration and passed into containers via `docker-compose.yml`. * **Environment Variables:** Security secrets (`API_KEY`, `JWT_SECRET`, `POSTGRES_PASSWORD`, `N8N_WHATSAPP_TRIGGER_URL`) are injected via the Portainer stack configuration and passed into containers via `docker-compose.yml`.
## 7. Environment Setup & Scripts ## 8. Environment Setup & Scripts
**Running Locally:** **Running Locally:**
1. Start the database: 1. Start the database:
@@ -110,13 +157,18 @@ cd backend
npm test npm test
``` ```
**Frontend Date/RFV Helper Test:**
```bash
node --experimental-strip-types --test src/dateRanges.test.ts
```
**Persistent Local Stack:** **Persistent Local Stack:**
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
The local Docker services use `restart: unless-stopped`, so containers should come back after laptop restart if Docker starts. The local Docker services use `restart: unless-stopped`, so containers should come back after laptop restart if Docker starts.
## 8. Coding Standards & AI Directives ## 9. Coding Standards & AI Directives
* **Strict Type Safety:** Use explicit TypeScript interfaces (defined in `types.ts`). Avoid `any` where possible. Do not bypass type checks with `// @ts-ignore`. * **Strict Type Safety:** Use explicit TypeScript interfaces (defined in `types.ts`). Avoid `any` where possible. Do not bypass type checks with `// @ts-ignore`.
* **Idiomatic React:** Use functional components and hooks (`useState`, `useEffect`, `useMemo`). Complex data transformations (like merging arrays into chart-ready datasets) MUST be wrapped in `useMemo` to prevent unnecessary re-renders. * **Idiomatic React:** Use functional components and hooks (`useState`, `useEffect`, `useMemo`). Complex data transformations (like merging arrays into chart-ready datasets) MUST be wrapped in `useMemo` to prevent unnecessary re-renders.
* **Tailwind Architecture:** All styling must be handled via Tailwind CSS utility classes. Avoid custom CSS files unless defining global font families or root variables in `index.css`. * **Tailwind Architecture:** All styling must be handled via Tailwind CSS utility classes. Avoid custom CSS files unless defining global font families or root variables in `index.css`.
@@ -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. * **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. * **API Security:** All backend modifications exposing or altering data MUST use the `verifyToken` middleware for frontend requests or `authenticateAPIKey` for external n8n webhooks.
* **Build Discipline:** After frontend/backend behavior changes, run `npm run lint`, `npm run build`, and relevant backend tests. The user prefers builds after changes to catch issues before deployment. * **Build Discipline:** After frontend/backend behavior changes, run `npm run lint`, `npm run build`, and relevant backend tests. The user prefers builds after changes to catch issues before deployment.
## 10. Recent Work
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.

View File

@@ -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 ## Local Development
Start PostgreSQL: Start PostgreSQL:
@@ -78,11 +93,11 @@ npm run dev
Default local URLs: Default local URLs:
```text ```text
Frontend: http://127.0.0.1:3001 Frontend: http://127.0.0.1:3002
Backend: http://127.0.0.1:3004 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 ## Environment
@@ -97,8 +112,17 @@ N8N_WHATSAPP_TRIGGER_URL
ADMIN_EMAIL ADMIN_EMAIL
ADMIN_PASSWORD ADMIN_PASSWORD
JWT_SECRET JWT_SECRET
TURNSTILE_SITE_KEY
TURNSTILE_SECRET
``` ```
`TURNSTILE_SECRET` enables backend CAPTCHA enforcement on `/api/login`. Set
`TURNSTILE_SITE_KEY` with Cloudflare's public site key so the login page can load
the verification widget at runtime.
The backend also accepts `VITE_TURNSTILE_SITE_KEY` as a site-key alias and
`TURNSTILE_SECRET_KEY` as a secret alias for older deployments.
## Validation ## Validation
```bash ```bash

View File

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

View File

@@ -5,6 +5,29 @@ const pool = new Pool({
connectionString: DATABASE_URL connectionString: DATABASE_URL
}); });
const defaultCatalogCategories = [
['Camiseta regular', 'Bases lisas e camisetas adultas.'],
['Camiseta infantil', 'Produtos infantis por cor e tamanho.'],
['Moletom', 'Moletons, cangurus e produtos de frio.'],
['Oversized', 'Modelagens oversized e variações relacionadas.'],
['Acessórios', 'Bonés, itens complementares e produtos não têxteis.'],
['DTF', 'Insumos e serviços relacionados a impressão DTF.'],
['Malha', 'Tecidos e malhas usados como matéria-prima.'],
['Aviamentos', 'Ribanas, linhas, ilhós e componentes de costura.'],
['Embalagens', 'Sacos, etiquetas, tags e materiais de expedição.'],
['Insumos gerais', 'Materiais de apoio sem família operacional específica.']
];
const seedDefaultCatalogCategories = async () => {
for (const [name, description] of defaultCatalogCategories) {
await pool.query(`
INSERT INTO catalog_categories (name, description, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT (name) DO NOTHING;
`, [name, description]);
}
};
const initDB = async () => { const initDB = async () => {
try { try {
await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`); await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`);
@@ -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 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 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 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(` await pool.query(`
ALTER TABLE orders 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(` await pool.query(`
CREATE TABLE IF NOT EXISTS app_users ( CREATE TABLE IF NOT EXISTS app_users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -148,7 +531,54 @@ const initDB = async () => {
}); });
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_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_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(` await pool.query(`
CREATE INDEX IF NOT EXISTS idx_orders_customer_key_date CREATE INDEX IF NOT EXISTS idx_orders_customer_key_date
ON orders ( ON orders (

View File

@@ -8,7 +8,13 @@ const formatOrderRow = (row) => ({
Valor_Unitario: parseFloat(row.valor_unitario), Valor_Unitario: parseFloat(row.valor_unitario),
Recebido_Em: row.created_at, Recebido_Em: row.created_at,
ID_Pedido: row.pedido_id, 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) => { const normalizeOrderDate = (dateValue) => {
@@ -35,6 +41,17 @@ const normalizeOrderDate = (dateValue) => {
return date.toISOString().slice(0, 10); 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 normalizeOrderPayload = (item) => {
const fallbackId = `${item.Nome_Cliente}_${item.Data_Pedido}_${item.Valor_Pedido}`; const fallbackId = `${item.Nome_Cliente}_${item.Data_Pedido}_${item.Valor_Pedido}`;
const orderId = item.id || item.ID_Pedido || (item.json && item.json.body && item.json.body.id) || fallbackId; const 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, parseInt(item.Quantidade, 10) || 0,
parseFloat(item.Valor_Unitario) || 0, parseFloat(item.Valor_Unitario) || 0,
String(orderId), 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']))
]; ];
}; };

View File

@@ -3,10 +3,14 @@ const { verifyToken } = require('../auth');
const { const {
getClientAnalytics, getClientAnalytics,
getClientDetailsAnalytics, getClientDetailsAnalytics,
getClientFilterOptions,
getClientPurchasePatternAnalytics,
getDashboardAnalytics, getDashboardAnalytics,
getProductAnalytics, getProductAnalytics,
getProductDetailsAnalytics,
getRfmAnalytics getRfmAnalytics
} = require('../services/analyticsService'); } = require('../services/analyticsService');
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
const router = express.Router(); const router = express.Router();
@@ -15,6 +19,13 @@ const getRange = (query) => ({
end: query.end 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) => { router.get('/analytics/dashboard', verifyToken, async (req, res) => {
try { try {
res.json(await getDashboardAnalytics(getRange(req.query))); res.json(await getDashboardAnalytics(getRange(req.query)));
@@ -33,15 +44,67 @@ 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) => { router.get('/analytics/clients', verifyToken, async (req, res) => {
try { try {
res.json(await getClientAnalytics(getRange(req.query))); res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));
} catch (error) { } catch (error) {
console.error('Error fetching client analytics:', error); console.error('Error fetching client analytics:', error);
res.status(500).json({ error: 'Internal Server 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) => { router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, res) => {
try { try {
const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query)); const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query));
@@ -59,7 +122,7 @@ router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, r
router.get('/analytics/rfm', verifyToken, async (req, res) => { router.get('/analytics/rfm', verifyToken, async (req, res) => {
try { try {
res.json(await getRfmAnalytics(getRange(req.query))); res.json(await getRfmAnalytics(getClientAnalyticsFilters(req.query)));
} catch (error) { } catch (error) {
console.error('Error fetching RFM analytics:', error); console.error('Error fetching RFM analytics:', error);
res.status(500).json({ error: 'Internal Server Error' }); res.status(500).json({ error: 'Internal Server Error' });

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View 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())

View File

@@ -8,6 +8,11 @@ const campaignRoutes = require('./routes/campaignRoutes');
const internalRoutes = require('./routes/internalRoutes'); const internalRoutes = require('./routes/internalRoutes');
const analyticsRoutes = require('./routes/analyticsRoutes'); const analyticsRoutes = require('./routes/analyticsRoutes');
const userRoutes = require('./routes/userRoutes'); 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 createApp = () => {
const app = express(); const app = express();
@@ -19,6 +24,11 @@ const createApp = () => {
app.use('/api', dataRoutes); app.use('/api', dataRoutes);
app.use('/api', stockRoutes); app.use('/api', stockRoutes);
app.use('/api', campaignRoutes); 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', analyticsRoutes);
app.use('/api', userRoutes); app.use('/api', userRoutes);
app.use('/api/internal', internalRoutes); app.use('/api/internal', internalRoutes);

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -15,8 +15,9 @@ const upsertOrders = async (payload) => {
const insertQuery = ` const insertQuery = `
INSERT INTO orders ( INSERT INTO orders (
cliente_nome, data_pedido, data_pedido_date, valor_pedido, cliente_nome, data_pedido, data_pedido_date, valor_pedido,
produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_fone produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_fone,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) 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 ON CONFLICT (pedido_id, produto_id) DO UPDATE SET
cliente_nome = EXCLUDED.cliente_nome, cliente_nome = EXCLUDED.cliente_nome,
data_pedido = EXCLUDED.data_pedido, data_pedido = EXCLUDED.data_pedido,
@@ -25,7 +26,13 @@ const upsertOrders = async (payload) => {
produto_descricao = EXCLUDED.produto_descricao, produto_descricao = EXCLUDED.produto_descricao,
quantidade = EXCLUDED.quantidade, quantidade = EXCLUDED.quantidade,
valor_unitario = EXCLUDED.valor_unitario, 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 created_at = CURRENT_TIMESTAMP
`; `;

File diff suppressed because it is too large Load Diff

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

View File

@@ -10,7 +10,12 @@ const {
isClientToken, isClientToken,
getClientAnalytics, getClientAnalytics,
getClientDetailsAnalytics, getClientDetailsAnalytics,
getClientFilterOptions,
getClientPurchasePatternAnalytics,
getDashboardAnalytics,
getPreviousDate, getPreviousDate,
getProductAnalytics,
getProductDetailsAnalytics,
getRecencyScore, getRecencyScore,
getRfmAnalytics, getRfmAnalytics,
getRfmSegment, getRfmSegment,
@@ -89,12 +94,130 @@ test('buildDateFilter ignores invalid bounds', () => {
); );
}); });
test('buildDateFilter composes client metadata predicates after date predicates', () => {
const filter = buildDateFilter({
start: '2026-05-01',
end: '2026-05-28',
marketplace: ' Mercado Livre ',
canal_venda: ' Online ',
seller: 'id:VEN-1'
});
assert.deepEqual(filter.params, ['2026-05-01', '2026-05-28', 'Mercado Livre', 'Online', 'VEN-1']);
assert.equal(filter.endParamIndex, 2);
assert.equal(
filter.whereClause,
"WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date AND NULLIF(TRIM(marketplace), '') = $3 AND NULLIF(TRIM(canal_venda), '') = $4 AND NULLIF(TRIM(id_vendedor), '') = $5"
);
});
test('buildDateFilter can filter seller by display name when no seller id is selected', () => {
const filter = buildDateFilter({ seller: 'name:Maria' });
assert.deepEqual(filter.params, ['%Maria%']);
assert.equal(
filter.whereClause,
"WHERE data_pedido_date IS NOT NULL AND NULLIF(TRIM(nome_vendedor), '') ILIKE $1"
);
});
test('buildDateFilter can search seller names from typed text', () => {
const filter = buildDateFilter({ seller: 'MARCUS' });
assert.deepEqual(filter.params, ['MARCUS', '%MARCUS%']);
assert.equal(
filter.whereClause,
"WHERE data_pedido_date IS NOT NULL AND (NULLIF(TRIM(id_vendedor), '') = $1 OR NULLIF(TRIM(nome_vendedor), '') ILIKE $2)"
);
});
test('getPreviousDate returns the calendar day before an ISO date', () => { test('getPreviousDate returns the calendar day before an ISO date', () => {
assert.equal(getPreviousDate('2026-06-15'), '2026-06-14'); assert.equal(getPreviousDate('2026-06-15'), '2026-06-14');
assert.equal(getPreviousDate('2026-03-01'), '2026-02-28'); assert.equal(getPreviousDate('2026-03-01'), '2026-02-28');
assert.equal(getPreviousDate('invalid'), null); assert.equal(getPreviousDate('invalid'), null);
}); });
test('getDashboardAnalytics includes seller revenue and order metrics', async () => {
const originalQuery = pool.query;
const calls = [];
pool.query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('total_revenue')) {
return {
rows: [{
total_revenue: 1000,
total_items: 25,
order_line_count: 5
}]
};
}
if (sql.includes('data_pedido_date::text as date')) {
return {
rows: [{ id: '977226210', name: 'KEDMA DA SILVA', date: '2026-06-05', value: 400, orders: 2 }]
};
}
if (sql.includes('order_hour')) {
return {
rows: [{ id: '977226210', name: 'KEDMA DA SILVA', hour: 14, value: 300, orders: 1 }]
};
}
if (sql.includes('seller_orders') && sql.includes('SUM(quantidade * valor_unitario)')) {
return {
rows: [{ id: '977226210', name: 'KEDMA DA SILVA', value: 750 }]
};
}
if (sql.includes('seller_orders') && sql.includes('COUNT(DISTINCT')) {
return {
rows: [{ id: '977226210', name: 'KEDMA DA SILVA', value: 3 }]
};
}
if (sql.includes('SUM(quantidade), 0) as value')) {
return {
rows: [{ id: 'P1', name: 'Produto A', value: 10 }]
};
}
return {
rows: [{ id: 'P1', name: 'Produto A', value: 1000 }]
};
};
try {
const dashboard = await getDashboardAnalytics({ start: '2026-06-01', end: '2026-06-24' });
const sellerRevenueQuery = calls.find(call => call.sql.includes('seller_orders') && call.sql.includes('SUM(quantidade * valor_unitario)'));
const sellerOrdersQuery = calls.find(call => call.sql.includes('seller_orders') && call.sql.includes('COUNT(DISTINCT'));
assert.equal(calls.length, 7);
calls.forEach(call => {
assert.deepEqual(call.params, ['2026-06-01', '2026-06-24']);
});
assert.match(sellerRevenueQuery.sql, /substring\(NULLIF\(TRIM\(nome_vendedor\), ''\) from/);
assert.match(sellerRevenueQuery.sql, /regexp_replace\(COALESCE\(nome_vendedor, ''\)/);
assert.match(sellerOrdersQuery.sql, /COUNT\(DISTINCT COALESCE/);
assert.deepEqual(dashboard.revenueBySeller, [
{ id: '977226210', name: 'KEDMA DA SILVA', value: 750 }
]);
assert.deepEqual(dashboard.ordersBySeller, [
{ id: '977226210', name: 'KEDMA DA SILVA', value: 3 }
]);
assert.deepEqual(dashboard.sellerRevenueByDate, [
{ id: '977226210', name: 'KEDMA DA SILVA', date: '2026-06-05', value: 400, orders: 2 }
]);
assert.deepEqual(dashboard.sellerRevenueByHour, [
{ id: '977226210', name: 'KEDMA DA SILVA', hour: 14, value: 300, orders: 1 }
]);
} finally {
pool.query = originalQuery;
}
});
test('client tokens are opaque and stable for customer keys', () => { test('client tokens are opaque and stable for customer keys', () => {
const phoneKey = '(16) 99103-6131'; const phoneKey = '(16) 99103-6131';
const nameKey = 'name:Cliente Sem Fone'; const nameKey = 'name:Cliente Sem Fone';
@@ -132,12 +255,12 @@ test('scoreTertile treats equal recency as high recency', () => {
test('getRecencyScore uses fixed lifecycle boundaries', () => { test('getRecencyScore uses fixed lifecycle boundaries', () => {
assert.equal(getRecencyScore(0), 3); assert.equal(getRecencyScore(0), 3);
assert.equal(getRecencyScore(60), 3); assert.equal(getRecencyScore(7), 3);
assert.equal(getRecencyScore(61), 2); assert.equal(getRecencyScore(8), 2);
assert.equal(getRecencyScore(180), 2); assert.equal(getRecencyScore(15), 2);
assert.equal(getRecencyScore(181), 1); assert.equal(getRecencyScore(16), 1);
assert.equal(getRecencyScore(365), 1); assert.equal(getRecencyScore(29), 1);
assert.equal(getRecencyScore(366), 1); assert.equal(getRecencyScore(30), 1);
}); });
test('getFrequencyScore uses fixed historical order thresholds', () => { test('getFrequencyScore uses fixed historical order thresholds', () => {
@@ -192,7 +315,7 @@ test('buildRfmClients requires five orders and top value for a recent Champion',
name: 'Potencial', name: 'Potencial',
monetary: 5000, monetary: 5000,
frequency: 4, frequency: 4,
recencyDays: 10, recencyDays: 7,
rfmFrequency: 4, rfmFrequency: 4,
rfmMonetary: 5000, rfmMonetary: 5000,
rfmMonetaryScore: 3 rfmMonetaryScore: 3
@@ -202,7 +325,7 @@ test('buildRfmClients requires five orders and top value for a recent Champion',
name: 'Champion', name: 'Champion',
monetary: 5000, monetary: 5000,
frequency: 5, frequency: 5,
recencyDays: 10, recencyDays: 7,
rfmFrequency: 5, rfmFrequency: 5,
rfmMonetary: 5000, rfmMonetary: 5000,
rfmMonetaryScore: 3 rfmMonetaryScore: 3
@@ -269,7 +392,7 @@ test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk
name: 'Novo', name: 'Novo',
monetary: 50, monetary: 50,
frequency: 1, frequency: 1,
recencyDays: 60, recencyDays: 7,
rfmFrequency: 1, rfmFrequency: 1,
rfmMonetary: 50, rfmMonetary: 50,
rfmFrequencyScore: 1, rfmFrequencyScore: 1,
@@ -280,7 +403,7 @@ test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk
name: 'Não é mais novo', name: 'Não é mais novo',
monetary: 50, monetary: 50,
frequency: 1, frequency: 1,
recencyDays: 61, recencyDays: 8,
rfmFrequency: 1, rfmFrequency: 1,
rfmMonetary: 50, rfmMonetary: 50,
rfmFrequencyScore: 1, rfmFrequencyScore: 1,
@@ -291,7 +414,7 @@ test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk
name: 'Hibernando', name: 'Hibernando',
monetary: 50, monetary: 50,
frequency: 1, frequency: 1,
recencyDays: 250, recencyDays: 16,
rfmFrequency: 1, rfmFrequency: 1,
rfmMonetary: 50, rfmMonetary: 50,
rfmFrequencyScore: 1, rfmFrequencyScore: 1,
@@ -302,7 +425,7 @@ test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk
name: 'Em Risco', name: 'Em Risco',
monetary: 100, monetary: 100,
frequency: 1, frequency: 1,
recencyDays: 250, recencyDays: 16,
rfmFrequency: 10, rfmFrequency: 10,
rfmMonetary: 1000, rfmMonetary: 1000,
rfmFrequencyScore: 3, rfmFrequencyScore: 3,
@@ -313,7 +436,7 @@ test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk
name: 'Perdido', name: 'Perdido',
monetary: 2000, monetary: 2000,
frequency: 1, frequency: 1,
recencyDays: 366, recencyDays: 30,
rfmFrequency: 1, rfmFrequency: 1,
rfmMonetary: 2000, rfmMonetary: 2000,
rfmFrequencyScore: 1, rfmFrequencyScore: 1,
@@ -330,6 +453,233 @@ test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk
assert.equal(byKey.get('lost').rfmScore, '113'); assert.equal(byKey.get('lost').rfmScore, '113');
}); });
test('getProductAnalytics returns exact product rows with stock and latest price', async () => {
const originalQuery = pool.query;
const calls = [];
pool.query = async (sql, params = []) => {
calls.push({ sql, params });
return {
rows: [
{
id: '919483307',
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
quantity_sold: 9513,
revenue: 113216.83,
order_line_count: 100,
last_price: 11.9,
stock: 11731,
first_sale_date: '2026-06-01',
last_sale_date: '2026-06-22'
},
{
id: 'stock-only',
name: 'Produto sem venda no período',
quantity_sold: 0,
revenue: 0,
order_line_count: 0,
last_price: 0,
stock: 12,
first_sale_date: null,
last_sale_date: null
}
]
};
};
try {
const products = await getProductAnalytics({ start: '2026-06-01', end: '2026-06-22' });
assert.equal(calls.length, 1);
assert.match(calls[0].sql, /WITH period_sales AS/);
assert.match(calls[0].sql, /FULL OUTER JOIN stock_rows/);
assert.doesNotMatch(calls[0].sql, /GROUP BY name/);
assert.deepEqual(calls[0].params, ['2026-06-01', '2026-06-22']);
assert.deepEqual(products[0], {
id: '919483307',
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
quantitySold: 9513,
revenue: 113216.83,
orderLineCount: 100,
lastPrice: 11.9,
stock: 11731,
firstSaleDate: '2026-06-01',
lastSaleDate: '2026-06-22'
});
assert.equal(products[1].id, 'stock-only');
assert.equal(products[1].stock, 12);
} finally {
pool.query = originalQuery;
}
});
test('getProductDetailsAnalytics returns product identity and period chart without raw order download', async () => {
const originalQuery = pool.query;
const calls = [];
pool.query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('selected_product AS')) {
return {
rows: [{
id: '919483307',
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
price: 11.9,
stock: 11731
}]
};
}
if (sql.includes('selected_base AS')) {
assert.match(sql, /COALESCE\(\s+CASE/);
assert.match(sql, /GROUP BY produto_id/);
assert.deepEqual(params, ['919483307', '2026-06-01', '2026-06-22']);
return {
rows: [
{
id: '919483307',
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
quantity_sold: 3,
revenue: 35.7,
order_count: 1
}
]
};
}
return {
rows: [
{
data_pedido_date: '2026-06-01',
date_label: '01-06-2026',
quantity_sold: 3,
revenue: 35.7,
order_count: 1
},
{
data_pedido_date: '2026-06-02',
date_label: '02-06-2026',
quantity_sold: 2,
revenue: 23.8,
order_count: 1
}
]
};
};
try {
const details = await getProductDetailsAnalytics('919483307', { start: '2026-06-01', end: '2026-06-22' });
assert.equal(calls.length, 3);
assert.match(calls[0].sql, /WITH selected_product AS/);
assert.match(calls[0].sql, /WHERE produto_id = \$1/);
assert.deepEqual(calls[0].params, ['919483307']);
assert.match(calls[1].sql, /produto_id = \$1/);
assert.match(calls[1].sql, /data_pedido_date >= \$2::date/);
assert.match(calls[1].sql, /data_pedido_date <= \$3::date/);
assert.deepEqual(calls[1].params, ['919483307', '2026-06-01', '2026-06-22']);
assert.deepEqual(details.productInfo, {
id: '919483307',
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
price: 11.9,
stock: 11731
});
assert.deepEqual(details.chartData, [
{ date: '01-06-2026', value: 3, quantitySold: 3, revenue: 35.7, orderCount: 1, averageTicket: 35.7 },
{ date: '02-06-2026', value: 2, quantitySold: 2, revenue: 23.8, orderCount: 1, averageTicket: 23.8 }
]);
assert.equal(details.totalSold, 5);
assert.equal(details.totalRevenue, 59.5);
assert.equal(details.totalOrders, 2);
assert.equal(details.averageTicket, 29.75);
assert.deepEqual(details.variantBreakdown, [
{
id: '919483307',
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
quantitySold: 3,
revenue: 35.7,
orderCount: 1
}
]);
} finally {
pool.query = originalQuery;
}
});
test('getProductDetailsAnalytics keeps known products visible with zero period sales', async () => {
const originalQuery = pool.query;
pool.query = async (sql) => {
if (sql.includes('selected_product AS')) {
return {
rows: [{
id: 'stock-only',
name: 'Produto sem venda no período',
price: 0,
stock: 12
}]
};
}
return { rows: [] };
};
try {
const details = await getProductDetailsAnalytics('stock-only', { start: '2026-06-01', end: '2026-06-22' });
assert.equal(details.productInfo.id, 'stock-only');
assert.equal(details.totalSold, 0);
assert.equal(details.totalRevenue, 0);
assert.deepEqual(details.chartData, []);
assert.deepEqual(details.variantBreakdown, []);
} finally {
pool.query = originalQuery;
}
});
test('getProductDetailsAnalytics groups single-day chart by hour', async () => {
const originalQuery = pool.query;
pool.query = async (sql) => {
if (sql.includes('selected_product AS')) {
return {
rows: [{
id: '919483307',
name: 'Produto com venda por hora',
price: 11.9,
stock: 4
}]
};
}
if (sql.includes('selected_base AS')) {
return { rows: [] };
}
assert.match(sql, /EXTRACT\(HOUR FROM created_at AT TIME ZONE 'America\/Sao_Paulo'\)::int as hour/);
return {
rows: [
{ hour: 9, quantity_sold: 2, revenue: 23.8, order_count: 1 },
{ hour: 18, quantity_sold: 3, revenue: 35.7, order_count: 1 }
]
};
};
try {
const details = await getProductDetailsAnalytics('919483307', { start: '2026-06-22', end: '2026-06-22' });
assert.equal(details.chartData.length, 24);
assert.deepEqual(details.chartData[9], { date: '09h', value: 2, quantitySold: 2, revenue: 23.8, orderCount: 1, averageTicket: 23.8 });
assert.deepEqual(details.chartData[18], { date: '18h', value: 3, quantitySold: 3, revenue: 35.7, orderCount: 1, averageTicket: 35.7 });
assert.equal(details.totalSold, 5);
assert.equal(details.totalRevenue, 59.5);
} finally {
pool.query = originalQuery;
}
});
test('getClientAnalytics returns opaque client tokens', async () => { test('getClientAnalytics returns opaque client tokens', async () => {
const originalQuery = pool.query; const originalQuery = pool.query;
const calls = []; const calls = [];
@@ -386,7 +736,153 @@ test('getClientAnalytics returns opaque client tokens', async () => {
} }
}); });
test('getClientDetailsAnalytics fetches only the tokenized client and period rows', async () => { test('getClientAnalytics groups phone-less history through the customer identity CTE', async () => {
const originalQuery = pool.query;
const calls = [];
pool.query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('INSERT INTO client_identity_tokens')) {
return { rows: [] };
}
return {
rows: [
{
customer_key: '(16) 99999-9999',
name: 'Cliente Com Historico',
phone: '(16) 99999-9999',
quantity_purchased: 1002,
total_spent: 50000,
order_count: 250,
last_purchase_date: '2026-06-15'
}
]
};
};
try {
const clients = await getClientAnalytics({ start: '2026-05-01', end: '2026-06-15' });
assert.equal(calls.length, 2);
assert.equal(clients.length, 1);
assert.equal(clients[0].customerKey, '(16) 99999-9999');
assert.equal(clients[0].phone, '(16) 99999-9999');
assert.match(calls[0].sql, /customer_phone_by_name AS/);
assert.match(calls[0].sql, /ARRAY_AGG\(NULLIF\(cliente_fone, ''\) ORDER BY data_pedido_date DESC NULLS LAST, id DESC\)/);
assert.match(calls[0].sql, /WHERE NULLIF\(cliente_fone, ''\) IS NOT NULL/);
assert.match(calls[0].sql, /NULLIF\(orders\.cliente_fone, ''\),\s+customer_phone_by_name\.canonical_phone/s);
assert.match(calls[0].sql, /FROM identity_orders/);
assert.match(calls[0].sql, /GROUP BY customer_key/);
} finally {
pool.query = originalQuery;
}
});
test('getClientFilterOptions returns all distinct order metadata options', async () => {
const originalQuery = pool.query;
const calls = [];
pool.query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('SELECT DISTINCT NULLIF(TRIM(marketplace)')) {
return { rows: [{ value: 'Mercado Livre' }, { value: 'Shopee' }] };
}
if (sql.includes('SELECT DISTINCT NULLIF(TRIM(canal_venda)')) {
return { rows: [{ value: 'Online' }] };
}
return {
rows: [
{ id: 'VEN-1', name: 'Maria' },
{ id: '', name: 'Sem ID' },
{ id: '', name: 'KEDMA DA SILVA #977226210' },
{ id: 'VEN-1', name: 'Maria' }
]
};
};
try {
const options = await getClientFilterOptions({ start: '2026-06-01', end: '2026-06-15' });
assert.equal(calls.length, 3);
calls.forEach(call => {
assert.deepEqual(call.params, []);
assert.doesNotMatch(call.sql, /data_pedido_date >=/);
assert.doesNotMatch(call.sql, /data_pedido_date <=/);
});
assert.match(calls[0].sql, /WHERE NULLIF\(TRIM\(marketplace\), ''\) IS NOT NULL/);
assert.match(calls[2].sql, /WITH seller_options AS/);
assert.match(calls[2].sql, /substring\(NULLIF\(TRIM\(nome_vendedor\), ''\) from/);
assert.match(calls[2].sql, /regexp_replace\(COALESCE\(nome_vendedor, ''\)/);
assert.match(calls[2].sql, /NULLIF\(TRIM\(nome_vendedor\), ''\) IS NOT NULL/);
assert.deepEqual(options.marketplaces, ['Mercado Livre', 'Shopee']);
assert.deepEqual(options.salesChannels, ['Online']);
assert.deepEqual(options.sellers, [
{ value: 'id:VEN-1', id: 'VEN-1', name: 'Maria' },
{ value: 'name:Sem ID', id: '', name: 'Sem ID' },
{ value: 'id:977226210', id: '977226210', name: 'KEDMA DA SILVA' }
]);
} finally {
pool.query = originalQuery;
}
});
test('getClientPurchasePatternAnalytics returns all-time weekday and recent hour counts', async () => {
const originalQuery = pool.query;
const calls = [];
pool.query = async (sql, params = []) => {
calls.push({ sql, params });
assert.match(sql, /order_events AS/);
assert.match(sql, /DISTINCT ON \(\s+customer_key,\s+COALESCE\(NULLIF\(pedido_id, ''\), data_pedido \|\| '_' \|\| valor_pedido::text\)\s+\)/);
if (sql.includes('EXTRACT(DOW FROM data_pedido_date)')) {
assert.deepEqual(params, []);
assert.doesNotMatch(sql, /created_at >= NOW\(\)/);
return {
rows: [
{ weekday: 1, order_count: 2 },
{ weekday: 5, order_count: 2 }
]
};
}
assert.deepEqual(params, [60]);
assert.match(sql, /created_at >= NOW\(\) - \(\$1::int \* INTERVAL '1 day'\)/);
assert.match(sql, /EXTRACT\(HOUR FROM created_at AT TIME ZONE 'America\/Sao_Paulo'\)::int as hour/);
return {
rows: [
{ hour: 9, order_count: 2 },
{ hour: 18, order_count: 1 },
{ hour: null, order_count: 1 }
]
};
};
try {
const pattern = await getClientPurchasePatternAnalytics();
assert.equal(calls.length, 2);
assert.equal(pattern.purchaseWeekdays.length, 7);
assert.equal(pattern.purchaseHours.length, 24);
assert.deepEqual(pattern.purchaseWeekdays[1], { label: 'Seg', value: 2 });
assert.deepEqual(pattern.purchaseWeekdays[5], { label: 'Sex', value: 2 });
assert.deepEqual(pattern.purchaseHours[9], { label: '09h', value: 2 });
assert.deepEqual(pattern.purchaseHours[18], { label: '18h', value: 1 });
assert.deepEqual(pattern.purchaseHours[0], { label: '00h', value: 0 });
assert.equal(pattern.weekdayRangeLabel, 'Todo período');
assert.equal(pattern.hourRangeLabel, 'Últimos 60 dias');
} finally {
pool.query = originalQuery;
}
});
test('getClientDetailsAnalytics resolves legacy name tokens to the canonical phone client', async () => {
const originalQuery = pool.query; const originalQuery = pool.query;
const calls = []; const calls = [];
const clientToken = createClientToken('name:Cliente Sem Fone'); const clientToken = createClientToken('name:Cliente Sem Fone');
@@ -398,21 +894,33 @@ test('getClientDetailsAnalytics fetches only the tokenized client and period row
return { rows: [{ customer_key: 'name:Cliente Sem Fone' }] }; return { rows: [{ customer_key: 'name:Cliente Sem Fone' }] };
} }
if (sql.includes('SELECT customer_key') && sql.includes('FROM identity_orders')) {
return { rows: [{ customer_key: '(16) 99999-9999' }] };
}
if (sql.includes('all_time_order_count')) { if (sql.includes('all_time_order_count')) {
return { return {
rows: [{ rows: [{
name: 'Cliente Sem Fone', name: 'Cliente Sem Fone',
phone: null, phone: '(16) 99999-9999',
all_time_order_count: 3 all_time_order_count: 3
}] }]
}; };
} }
if (sql.includes('created_at >= NOW()')) {
return {
rows: [
{ hour: 9, order_count: 1 }
]
};
}
return { return {
rows: [ rows: [
{ {
cliente_nome: 'Cliente Sem Fone', cliente_nome: 'Cliente Sem Fone',
cliente_fone: null, cliente_fone: '',
data_pedido: '10-06-2026', data_pedido: '10-06-2026',
data_pedido_date: '2026-06-10', data_pedido_date: '2026-06-10',
valor_pedido: 25, valor_pedido: 25,
@@ -424,7 +932,7 @@ test('getClientDetailsAnalytics fetches only the tokenized client and period row
}, },
{ {
cliente_nome: 'Cliente Sem Fone', cliente_nome: 'Cliente Sem Fone',
cliente_fone: null, cliente_fone: '(16) 99999-9999',
data_pedido: '10-06-2026', data_pedido: '10-06-2026',
data_pedido_date: '2026-06-10', data_pedido_date: '2026-06-10',
valor_pedido: 25, valor_pedido: 25,
@@ -441,22 +949,34 @@ test('getClientDetailsAnalytics fetches only the tokenized client and period row
try { try {
const details = await getClientDetailsAnalytics(clientToken, { start: '2026-06-01', end: '2026-06-15' }); const details = await getClientDetailsAnalytics(clientToken, { start: '2026-06-01', end: '2026-06-15' });
assert.equal(calls.length, 3); assert.equal(calls.length, 6);
assert.match(calls[0].sql, /FROM client_identity_tokens/); assert.match(calls[0].sql, /FROM client_identity_tokens/);
assert.deepEqual(calls[0].params, [clientToken]); assert.deepEqual(calls[0].params, [clientToken]);
assert.match(calls[1].sql, /WHERE COALESCE\(NULLIF\(cliente_fone, ''\), 'name:' \|\| COALESCE\(NULLIF\(cliente_nome, ''\), 'Cliente Desconhecido'\)\) = \$1/); assert.match(calls[1].sql, /SELECT customer_key/);
assert.deepEqual(calls[1].params, ['name:Cliente Sem Fone']); assert.deepEqual(calls[1].params, ['Cliente Sem Fone']);
assert.match(calls[2].sql, /data_pedido_date >= \$2::date/); assert.match(calls[2].sql, /FROM identity_orders/);
assert.match(calls[2].sql, /data_pedido_date <= \$3::date/); assert.match(calls[2].sql, /WHERE customer_key = \$1/);
assert.deepEqual(calls[2].params, ['name:Cliente Sem Fone', '2026-06-01', '2026-06-15']); assert.deepEqual(calls[2].params, ['(16) 99999-9999']);
assert.match(calls[3].sql, /data_pedido_date >= \$2::date/);
assert.match(calls[3].sql, /data_pedido_date <= \$3::date/);
assert.deepEqual(calls[3].params, ['(16) 99999-9999', '2026-06-01', '2026-06-15']);
assert.match(calls[4].sql, /data_pedido_date IS NOT NULL/);
assert.doesNotMatch(calls[4].sql, /data_pedido_date >=/);
assert.doesNotMatch(calls[4].sql, /data_pedido_date <=/);
assert.deepEqual(calls[4].params, ['(16) 99999-9999']);
assert.match(calls[5].sql, /created_at >= NOW\(\) - \(\$2::int \* INTERVAL '1 day'\)/);
assert.deepEqual(calls[5].params, ['(16) 99999-9999', 60]);
assert.equal(details.clientName, 'Cliente Sem Fone'); assert.equal(details.clientName, 'Cliente Sem Fone');
assert.equal(details.clientPhone, ''); assert.equal(details.clientPhone, '(16) 99999-9999');
assert.equal(details.allTimeOrderCount, 3); assert.equal(details.allTimeOrderCount, 3);
assert.equal(details.periodSpent, 25); assert.equal(details.periodSpent, 25);
assert.equal(details.periodItems, 3); assert.equal(details.periodItems, 3);
assert.equal(details.periodOrderCount, 1); assert.equal(details.periodOrderCount, 1);
assert.equal(details.periodAverageTicket, 25); assert.equal(details.periodAverageTicket, 25);
assert.deepEqual(details.chartData, [{ date: '10-06-2026', value: 25 }]); assert.deepEqual(details.chartData, [{ date: '10-06-2026', value: 25 }]);
assert.equal(details.purchaseWeekdayRangeLabel, 'Todo período');
assert.equal(details.purchaseHourRangeLabel, 'Últimos 60 dias');
assert.deepEqual(details.purchaseHours[9], { label: '09h', value: 1 });
assert.equal(details.groupedOrders.length, 1); assert.equal(details.groupedOrders.length, 1);
assert.equal(details.groupedOrders[0].orderTotal, 25); assert.equal(details.groupedOrders[0].orderTotal, 25);
assert.equal(details.groupedOrders[0].items.length, 2); assert.equal(details.groupedOrders[0].items.length, 2);
@@ -465,6 +985,87 @@ test('getClientDetailsAnalytics fetches only the tokenized client and period row
} }
}); });
test('getClientDetailsAnalytics groups single-day spend chart by hour', async () => {
const originalQuery = pool.query;
const clientToken = createClientToken('(16) 99999-9999');
pool.query = async (sql, params = []) => {
if (sql.includes('FROM client_identity_tokens')) {
return { rows: [{ customer_key: '(16) 99999-9999' }] };
}
if (sql.includes('SELECT customer_key') && sql.includes('FROM identity_orders')) {
return { rows: [{ customer_key: '(16) 99999-9999' }] };
}
if (sql.includes('all_time_order_count')) {
return {
rows: [{
name: 'Cliente Teste',
phone: '(16) 99999-9999',
all_time_order_count: 2
}]
};
}
if (sql.includes('created_at >= NOW()')) {
return {
rows: [
{ hour: 9, order_count: 1 },
{ hour: 18, order_count: 1 }
]
};
}
assert.deepEqual(params[0], '(16) 99999-9999');
return {
rows: [
{
cliente_nome: 'Cliente Teste',
cliente_fone: '(16) 99999-9999',
data_pedido: '22-06-2026',
data_pedido_date: '2026-06-22',
valor_pedido: 20,
produto_id: 'produto-1',
produto_descricao: 'Produto A',
quantidade: 2,
valor_unitario: 10,
pedido_id: 'pedido-1',
created_at: '2026-06-22T09:15:00.000-03:00'
},
{
cliente_nome: 'Cliente Teste',
cliente_fone: '(16) 99999-9999',
data_pedido: '22-06-2026',
data_pedido_date: '2026-06-22',
valor_pedido: 30,
produto_id: 'produto-2',
produto_descricao: 'Produto B',
quantidade: 1,
valor_unitario: 30,
pedido_id: 'pedido-2',
created_at: '2026-06-22T18:30:00.000-03:00'
}
]
};
};
try {
const details = await getClientDetailsAnalytics(clientToken, { start: '2026-06-22', end: '2026-06-22' });
assert.equal(details.chartData.length, 24);
assert.deepEqual(details.chartData[9], { date: '09h', value: 20 });
assert.deepEqual(details.chartData[18], { date: '18h', value: 30 });
assert.deepEqual(details.purchaseHours[9], { label: '09h', value: 1 });
assert.deepEqual(details.purchaseHours[18], { label: '18h', value: 1 });
assert.equal(details.purchaseWeekdayRangeLabel, 'Todo período');
assert.equal(details.purchaseHourRangeLabel, 'Últimos 60 dias');
assert.equal(details.periodSpent, 50);
} finally {
pool.query = originalQuery;
}
});
test('getClientDetailsAnalytics rejects invalid client tokens before querying', async () => { test('getClientDetailsAnalytics rejects invalid client tokens before querying', async () => {
const originalQuery = pool.query; const originalQuery = pool.query;
pool.query = async () => { pool.query = async () => {
@@ -581,7 +1182,8 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
assert.match(calls[1].sql, /SET LOCAL statement_timeout/); assert.match(calls[1].sql, /SET LOCAL statement_timeout/);
assert.deepEqual(selectCalls[0].params, ['2026-06-09', '2026-06-15']); assert.deepEqual(selectCalls[0].params, ['2026-06-09', '2026-06-15']);
assert.doesNotMatch(selectCalls[0].sql, /cliente_fone IS NOT NULL/); assert.doesNotMatch(selectCalls[0].sql, /WHERE\s+cliente_fone IS NOT NULL/);
assert.match(selectCalls[0].sql, /FROM identity_orders/);
assert.match(selectCalls[1].sql, /\(\$1::date - MAX\(data_pedido_date\)\)::int/); assert.match(selectCalls[1].sql, /\(\$1::date - MAX\(data_pedido_date\)\)::int/);
assert.match(selectCalls[1].sql, /data_pedido_date <= \$1::date/); assert.match(selectCalls[1].sql, /data_pedido_date <= \$1::date/);
assert.match(selectCalls[1].sql, /WHEN frequency <= 1 THEN 1/); assert.match(selectCalls[1].sql, /WHEN frequency <= 1 THEN 1/);
@@ -589,7 +1191,8 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
assert.doesNotMatch(selectCalls[1].sql, /PERCENT_RANK\(\) OVER \(ORDER BY frequency\)/); assert.doesNotMatch(selectCalls[1].sql, /PERCENT_RANK\(\) OVER \(ORDER BY frequency\)/);
assert.match(selectCalls[1].sql, /PERCENT_RANK\(\) OVER \(ORDER BY monetary\)/); assert.match(selectCalls[1].sql, /PERCENT_RANK\(\) OVER \(ORDER BY monetary\)/);
assert.match(selectCalls[1].sql, /customer_key = ANY\(\$2::text\[\]\)/); assert.match(selectCalls[1].sql, /customer_key = ANY\(\$2::text\[\]\)/);
assert.doesNotMatch(selectCalls[1].sql, /cliente_fone IS NOT NULL/); assert.doesNotMatch(selectCalls[1].sql, /WHERE\s+cliente_fone IS NOT NULL/);
assert.match(selectCalls[1].sql, /FROM identity_orders/);
assert.deepEqual(selectCalls[1].params, ['2026-06-15', ['1', '4', 'name:Cliente Sem Fone']]); assert.deepEqual(selectCalls[1].params, ['2026-06-15', ['1', '4', 'name:Cliente Sem Fone']]);
assert.deepEqual(selectCalls[2].params, ['2026-06-14', '2026-06-14']); assert.deepEqual(selectCalls[2].params, ['2026-06-14', '2026-06-14']);
assert.deepEqual(selectCalls[3].params, ['2026-06-14', ['1', '4', 'name:Cliente Sem Fone']]); assert.deepEqual(selectCalls[3].params, ['2026-06-14', ['1', '4', 'name:Cliente Sem Fone']]);

View File

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

View File

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

View File

@@ -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: '' }
});
});

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,11 @@ services:
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123} - JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp} - N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
- TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY:-}
- TURNSTILE_SITEKEY=${TURNSTILE_SITEKEY:-}
- VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY:-}
- TURNSTILE_SECRET=${TURNSTILE_SECRET:-}
- TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY:-}
depends_on: depends_on:
- db - db
restart: unless-stopped restart: unless-stopped
@@ -37,7 +42,7 @@ services:
image: gitea.blyzer.com.br/blyzer/graphs-frontend:latest image: gitea.blyzer.com.br/blyzer/graphs-frontend:latest
container_name: graph_frontend container_name: graph_frontend
ports: ports:
- "3005:80" - "3002:80"
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped

View File

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

View File

@@ -7,10 +7,17 @@ import { isAuthenticated, isSuperAdmin } from './dataService';
const Dashboard = React.lazy(() => import('./pages/Dashboard')); const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Products = React.lazy(() => import('./pages/Products')); const Products = React.lazy(() => import('./pages/Products'));
const ProductDetails = React.lazy(() => import('./pages/ProductDetails')); 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 Clients = React.lazy(() => import('./pages/Clients'));
const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
const Campaigns = React.lazy(() => import('./pages/Campaigns')); const Campaigns = React.lazy(() => import('./pages/Campaigns'));
const Rfm = React.lazy(() => import('./pages/Rfm')); const Rfm = React.lazy(() => import('./pages/Rfm'));
const Registrations = React.lazy(() => import('./pages/Registrations'));
const Login = React.lazy(() => import('./pages/Login')); const Login = React.lazy(() => import('./pages/Login'));
const AdminUsers = React.lazy(() => import('./pages/AdminUsers')); const AdminUsers = React.lazy(() => import('./pages/AdminUsers'));
@@ -44,11 +51,21 @@ function App() {
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}> <Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
<Route path="graph" element={<Dashboard />} /> <Route path="graph" element={<Dashboard />} />
<Route path="products" element={<Products />} /> <Route path="products" element={<Products />} />
<Route path="products/groups/:groupKey" element={<ProductGroupDetails />} />
<Route path="products/:id" element={<ProductDetails />} /> <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" element={<Clients />} />
<Route path="clients/:clientToken" element={<ClientDetails />} /> <Route path="clients/:clientToken" element={<ClientDetails />} />
<Route path="rfm" element={<Rfm />} /> <Route path="rfm" element={<Rfm />} />
<Route path="campaigns" element={<Campaigns />} /> <Route path="campaigns" element={<Campaigns />} />
<Route path="registrations" element={<Registrations />} />
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} /> <Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -38,6 +38,14 @@ export interface ClientDetailsMetrics {
date: string; date: string;
value: number; value: number;
}>; }>;
purchaseWeekdays: Array<{
label: string;
value: number;
}>;
purchaseHours: Array<{
label: string;
value: number;
}>;
groupedOrders: GroupedClientOrder[]; groupedOrders: GroupedClientOrder[];
allTimeOrderCount: number; allTimeOrderCount: number;
clientName: string; clientName: string;
@@ -49,6 +57,22 @@ export interface ClientDetailsMetrics {
periodItems: number; periodItems: number;
} }
const WEEKDAY_LABELS = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab'];
const getOrderHour = (order: OrderData): number | null => {
const timestamp = order.Recebido_Em || (/\d{1,2}:\d{2}/.test(order.Data_Pedido || '') ? order.Data_Pedido : '');
if (!timestamp) return null;
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime())) return date.getHours();
const timeMatch = String(timestamp).match(/\b(\d{1,2}):\d{2}/);
if (!timeMatch) return null;
const hour = Number(timeMatch[1]);
return hour >= 0 && hour <= 23 ? hour : null;
};
const scoreTertile = (value: number, values: number[], higherIsBetter = true) => { const scoreTertile = (value: number, values: number[], higherIsBetter = true) => {
const numericValues = values.filter(Number.isFinite); const numericValues = values.filter(Number.isFinite);
if (!numericValues.length) return 1; if (!numericValues.length) return 1;
@@ -230,9 +254,36 @@ export const buildClientDetailsMetrics = (ordersData: OrderData[], customerKey:
date, date,
value: spentByDate[date] value: spentByDate[date]
})).sort((a, b) => parseOrderDate(a.date).getTime() - parseOrderDate(b.date).getTime()); })).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 { return {
chartData, chartData,
purchaseWeekdays: weekdayCounts,
purchaseHours: hourCounts,
groupedOrders, groupedOrders,
allTimeOrderCount: allTimeOrderIds.size, allTimeOrderCount: allTimeOrderIds.size,
clientName, clientName,

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

View File

@@ -1,11 +1,13 @@
import type { DashboardAnalytics, DateRange, OrderData } from '../types'; 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 = [ const COLORS = [
'#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316', '#25C2FF', '#18D6B5', '#A06BFF', '#FF6B8A', '#FFC247',
'#06b6d4', '#ec4899', '#eab308', '#6366f1', '#14b8a6', '#70E083', '#6EA5FF', '#FF6EC7', '#FF8A63', '#22C7E6',
'#6ee7b7', '#93c5fd', '#c4b5fd', '#fda4af', '#fdba74', '#B8E84D', '#FFA24A', '#B992FF', '#38E0C4', '#FFD166',
'#67e8f9', '#f9a8d4', '#fde047', '#a5b4fc', '#5eead4' '#FF7A9B', '#82B8FF', '#52DFA0', '#D28BFF', '#F7B23B'
]; ];
const globalColorMap: Record<string, string> = {}; const globalColorMap: Record<string, string> = {};
@@ -19,6 +21,10 @@ const getProductColor = (name: string): string => {
return globalColorMap[name]; return globalColorMap[name];
}; };
const formatSellerDisplayName = (value: string) => (
normalizeUnknownLabel(formatDisplayName(removeTrailingSellerId(value)), 'Sem vendedor')
);
export interface ChartProductMetric { export interface ChartProductMetric {
name: string; name: string;
id: string; id: string;
@@ -26,18 +32,36 @@ export interface ChartProductMetric {
fill: string; fill: string;
} }
export interface SellerDateRevenueMetric extends ChartProductMetric {
date: string;
orders: number;
}
export interface SellerHourRevenueMetric extends ChartProductMetric {
hour: number;
orders: number;
}
export interface DashboardMetrics { export interface DashboardMetrics {
totalRevenue: number; totalRevenue: number;
totalOrders: number; totalOrders: number;
averageOrderValue: number; averageOrderValue: number;
salesByProduct: ChartProductMetric[]; salesByProduct: ChartProductMetric[];
revenueByProduct: ChartProductMetric[]; revenueByProduct: ChartProductMetric[];
revenueBySeller: ChartProductMetric[];
ordersBySeller: ChartProductMetric[];
sellerRevenueByDate: SellerDateRevenueMetric[];
sellerRevenueByHour: SellerHourRevenueMetric[];
} }
export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetrics => { export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetrics => {
const displayProducts = Array.from(new Set([ const displayProducts = Array.from(new Set([
...metrics.salesByProduct.map(product => product.name), ...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(); ])).sort();
const productColors = displayProducts.reduce<Record<string, string>>((colors, name) => { const productColors = displayProducts.reduce<Record<string, string>>((colors, name) => {
colors[name] = getProductColor(name); colors[name] = getProductColor(name);
@@ -55,6 +79,28 @@ export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetr
revenueByProduct: metrics.revenueByProduct.map(product => ({ revenueByProduct: metrics.revenueByProduct.map(product => ({
...product, ...product,
fill: productColors[product.name] 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 productSalesMap: Record<string, number> = {};
const productRevenueMap: Record<string, number> = {}; const productRevenueMap: Record<string, number> = {};
const productNameIdMap: Record<string, string> = {}; 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 => { filteredData.forEach(order => {
const itemRevenue = getOrderItemRevenue(order); const itemRevenue = getOrderItemRevenue(order);
const productName = getBaseProductName(order.Descricao_Produto); 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; revenue += itemRevenue;
totalItems += order.Quantidade; totalItems += order.Quantidade;
productNameIdMap[productName] = order.ID_Produto; productNameIdMap[productName] = order.ID_Produto;
productSalesMap[productName] = (productSalesMap[productName] || 0) + order.Quantidade; productSalesMap[productName] = (productSalesMap[productName] || 0) + order.Quantidade;
productRevenueMap[productName] = (productRevenueMap[productName] || 0) + itemRevenue; 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 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 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 displayProducts = Array.from(new Set([...topSalesNames, ...topRevenueNames])).sort();
const productColors = displayProducts.reduce<Record<string, string>>((colors, name) => { const productColors = displayProducts.reduce<Record<string, string>>((colors, name) => {
colors[name] = getProductColor(name); colors[name] = getProductColor(name);
@@ -100,11 +184,56 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa
fill: productColors[name] fill: productColors[name]
})); }));
const revenueBySeller = topSellerRevenueNames.map(name => ({
name,
id: sellerIdMap[name],
value: sellerRevenueMap[name],
fill: getProductColor(name)
}));
const ordersBySeller = topSellerOrderNames.map(name => ({
name,
id: sellerIdMap[name],
value: sellerOrderKeysMap[name].size,
fill: getProductColor(name)
}));
const sellerRevenueByDate = topSellerRevenueNames.flatMap(name => (
Object.keys(sellerRevenueByDateMap[name] || {})
.sort()
.map(date => ({
name,
id: sellerIdMap[name],
date,
value: sellerRevenueByDateMap[name][date],
orders: sellerOrderKeysByDateMap[name]?.[date]?.size || 0,
fill: getProductColor(name)
}))
));
const sellerRevenueByHour = topSellerRevenueNames.flatMap(name => (
Object.keys(sellerRevenueByHourMap[name] || {})
.map(Number)
.sort((a, b) => a - b)
.map(hour => ({
name,
id: sellerIdMap[name],
hour,
value: sellerRevenueByHourMap[name][hour],
orders: sellerOrderKeysByHourMap[name]?.[hour]?.size || 0,
fill: getProductColor(name)
}))
));
return { return {
totalRevenue: revenue, totalRevenue: revenue,
totalOrders: totalItems, totalOrders: totalItems,
averageOrderValue: revenue / (filteredData.length || 1), averageOrderValue: revenue / (filteredData.length || 1),
salesByProduct, salesByProduct,
revenueByProduct revenueByProduct,
revenueBySeller,
ordersBySeller,
sellerRevenueByDate,
sellerRevenueByHour
}; };
}; };

26
src/catalogLinks.ts Normal file
View 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
View 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;
};

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

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react'; import React, { useRef, useState } from 'react';
import { Calendar, RefreshCw, ChevronDown } from 'lucide-react'; import { Calendar, RefreshCw, ChevronDown } from 'lucide-react';
import type { DateRange } from '../types'; import type { DateRange } from '../types';
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges'; import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
@@ -32,10 +32,18 @@ const REFRESH_OPTIONS = [
{ label: '5m', value: 300000 }, { 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 DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange, refreshInterval, setRefreshInterval, onManualRefresh }) => {
const [isPresetOpen, setIsPresetOpen] = useState(false); const [isPresetOpen, setIsPresetOpen] = useState(false);
const [customDraft, setCustomDraft] = useState(() => ({
appliedKey: getRangeKey(dateRange),
range: dateRange
}));
const startRef = useRef<HTMLInputElement>(null); const startRef = useRef<HTMLInputElement>(null);
const endRef = useRef<HTMLInputElement>(null); const endRef = useRef<HTMLInputElement>(null);
const appliedRangeKey = getRangeKey(dateRange);
const draftRange = customDraft.appliedKey === appliedRangeKey ? customDraft.range : dateRange;
const formatDisplayDate = (date: Date) => { const formatDisplayDate = (date: Date) => {
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }); 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); const newStart = parseLocalDateInput(e.target.value);
if (newStart && !isNaN(newStart.getTime())) { if (newStart && !isNaN(newStart.getTime())) {
const start = startOfLocalDay(newStart); const start = startOfLocalDay(newStart);
const end = dateRange.end < start ? endOfLocalDay(start) : dateRange.end; const end = draftRange.end < start ? endOfLocalDay(start) : draftRange.end;
onChange({ start, end }); setCustomDraft({ appliedKey: appliedRangeKey, range: { start, end } });
} }
}; };
@@ -58,11 +66,19 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
const newEnd = parseLocalDateInput(e.target.value); const newEnd = parseLocalDateInput(e.target.value);
if (newEnd && !isNaN(newEnd.getTime())) { if (newEnd && !isNaN(newEnd.getTime())) {
const end = endOfLocalDay(newEnd); const end = endOfLocalDay(newEnd);
const start = dateRange.start > end ? startOfLocalDay(end) : dateRange.start; const start = draftRange.start > end ? startOfLocalDay(end) : draftRange.start;
onChange({ start, end }); 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>) => { const openPicker = (ref: React.RefObject<HTMLInputElement | null>) => {
if (ref.current) { if (ref.current) {
try { 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" 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)} onClick={() => openPicker(startRef)}
> >
<span className="w-full text-center">{formatDisplayDate(dateRange.start)}</span> <span className="w-full text-center">{formatDisplayDate(draftRange.start)}</span>
<input <input
ref={startRef} ref={startRef}
type="date" type="date"
value={formatDateForInput(dateRange.start)} value={formatDateForInput(draftRange.start)}
onChange={handleStartChange} onChange={handleStartChange}
className="absolute inset-0 opacity-0 cursor-pointer" 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" 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)} onClick={() => openPicker(endRef)}
> >
<span className="w-full text-center">{formatDisplayDate(dateRange.end)}</span> <span className="w-full text-center">{formatDisplayDate(draftRange.end)}</span>
<input <input
ref={endRef} ref={endRef}
type="date" type="date"
value={formatDateForInput(dateRange.end)} value={formatDateForInput(draftRange.end)}
onChange={handleEndChange} onChange={handleEndChange}
className="absolute inset-0 opacity-0 cursor-pointer" className="absolute inset-0 opacity-0 cursor-pointer"
/> />
</div> </div>
</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>
</div> </div>
<span className="px-4 text-xs font-bold text-dark-muted uppercase tracking-widest mb-1 block">Atalhos</span> <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 <button
key={preset.label} key={preset.label}
onClick={() => { onClick={() => {
onChange(preset.getRange()); const nextRange = preset.getRange();
setCustomDraft({ appliedKey: getRangeKey(nextRange), range: nextRange });
onChange(nextRange);
setIsPresetOpen(false); 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" 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"

View File

@@ -1,16 +1,21 @@
import { useCallback, useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone, Grid3X3, Shield } from 'lucide-react'; import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList } from 'lucide-react';
import type { DateRange, OrderData, StockData } from '../types'; import type { DateRange, OrderData } from '../types';
import { fetchData, fetchStock, isSuperAdmin, logout } from '../dataService'; import { isSuperAdmin, logout } from '../dataService';
import { rangeForLastDays } from '../dateRanges'; import { rangeForLastDays } from '../dateRanges';
const emptyOrdersData: OrderData[] = [];
type ThemeMode = 'dark' | 'offwhite';
const Layout = () => { const Layout = () => {
const location = useLocation(); const location = useLocation();
const needsRawData = location.pathname.startsWith('/products');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('graph_sidebar_collapsed') === 'true'; 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 [dateRange, setDateRange] = useState<DateRange>(() => {
const saved = localStorage.getItem('nexstar_date_range'); const saved = localStorage.getItem('nexstar_date_range');
@@ -23,42 +28,16 @@ const Layout = () => {
return rangeForLastDays(30); return rangeForLastDays(30);
}); });
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
const [stockData, setStockData] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(needsRawData);
const [refreshInterval, setRefreshInterval] = useState<number>(() => { const [refreshInterval, setRefreshInterval] = useState<number>(() => {
const saved = localStorage.getItem('nexstar_refresh_interval'); const saved = localStorage.getItem('nexstar_refresh_interval');
return saved ? Number(saved) : 0; 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(() => { useEffect(() => {
if (!needsRawData) return; document.documentElement.classList.add('dark');
document.documentElement.dataset.theme = themeMode;
// Product pages still depend on raw orders until their API migration is complete. localStorage.setItem('nexstar_theme', themeMode);
// eslint-disable-next-line react-hooks/set-state-in-effect }, [themeMode]);
void loadData(true);
}, [loadData, needsRawData]);
useEffect(() => {
if (refreshInterval === 0 || !needsRawData) return;
const intervalId = setInterval(() => {
loadData(false);
}, refreshInterval);
return () => clearInterval(intervalId);
}, [loadData, needsRawData, refreshInterval]);
useEffect(() => { useEffect(() => {
localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString()); localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString());
@@ -76,29 +55,47 @@ const Layout = () => {
setIsSidebarCollapsed(newState); setIsSidebarCollapsed(newState);
localStorage.setItem('graph_sidebar_collapsed', String(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() const adminNavigation = isSuperAdmin()
? [{ name: 'Usuários', href: '/admin/users', icon: Shield }] ? [{ name: 'Usuários', href: '/admin/users', icon: Shield }]
: []; : [];
const navigationSections = [ 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 }] : []), ...(adminNavigation.length ? [{ label: 'Super admin', items: adminNavigation }] : []),
]; ];
return ( 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 */} {/* Sidebar */}
<aside className={`bg-dark-sidebar border-r border-dark-border flex flex-col transition-all duration-300 ${isSidebarCollapsed ? 'w-20' : 'w-64'}`}> <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={`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="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" /> <BarChart3 className="w-6 h-6" />
</div> </div>
{!isSidebarCollapsed && <span className="text-xl font-bold text-dark-text">Nexstar</span>} {!isSidebarCollapsed && <span className="text-xl font-bold text-dark-text">Nexstar</span>}
@@ -118,11 +115,16 @@ const Layout = () => {
</div> </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) => ( {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 && ( {!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} {section.label}
</div> </div>
)} )}
@@ -132,17 +134,17 @@ const Layout = () => {
<Link <Link
key={item.name} key={item.name}
to={item.href} to={item.href}
className={`flex items-center rounded-xl px-4 py-3 transition-all ${ className={`group flex min-h-11 items-center rounded-lg px-3 py-2.5 text-sm transition-all ${
isSidebarCollapsed ? 'justify-center' : 'space-x-3' isSidebarCollapsed ? 'justify-center' : 'gap-3'
} ${ } ${
isActive isActive
? 'bg-brand-primary/10 text-brand-primary font-semibold shadow-md shadow-brand-primary/5' ? '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-card hover:text-dark-text' : 'text-dark-muted hover:bg-dark-input/70 hover:text-dark-text'
}`} }`}
title={isSidebarCollapsed ? item.name : undefined} title={isSidebarCollapsed ? item.name : undefined}
> >
<item.icon className="h-5 w-5 shrink-0" /> <item.icon className="h-4.5 w-4.5 shrink-0" />
{!isSidebarCollapsed && <span className="font-medium">{item.name}</span>} {!isSidebarCollapsed && <span className="truncate font-medium">{item.name}</span>}
</Link> </Link>
); );
})} })}
@@ -164,19 +166,22 @@ const Layout = () => {
{/* Main Content */} {/* Main Content */}
<main className="flex-1 flex flex-col h-screen overflow-hidden"> <main className="flex-1 flex flex-col h-screen overflow-hidden">
{/* Header */} {/* 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> <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> </header>
{/* Content Area */} {/* Content Area */}
<div className="flex-1 overflow-y-auto p-8 relative"> <div className="app-content flex-1 overflow-y-auto p-8 relative">
{needsRawData && isLoading && ( <Outlet context={{ dateRange, setDateRange, ordersData: emptyOrdersData, isDataLoading: false, refreshInterval, setRefreshInterval, themeMode }} />
<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> </div>
</main> </main>
</div> </div>

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

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,96 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, 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'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; 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 { try {
const response = await fetch(`${API_URL}/login`, { const response = await fetch(`${API_URL}/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password, captchaToken }),
}); });
if (response.ok) { if (response.ok) {
const data = await response.json() as { token?: string; user?: AuthUser }; const data = await response.json() as { token?: string; user?: AuthUser };
if (!data.token || !data.user) return false; if (!data.token || !data.user) return 'server_error';
localStorage.setItem('auth_token', data.token); localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(data.user)); localStorage.setItem('auth_user', JSON.stringify(data.user));
return true; return 'success';
} }
return false; if (response.status === 403) return 'captcha_failed';
return 'invalid_credentials';
} catch (error) { } catch (error) {
console.error('Login failed', error); console.error('Login failed', error);
return false; return 'server_error';
} }
}; };
@@ -109,64 +176,530 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
return response; return response;
}; };
export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<DashboardAnalytics | null> => { export const downloadDatabaseDiagnostic = async (): Promise<void> => {
const response = await authFetch('/admin/database-diagnostic', { cache: 'no-store' });
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(data?.error || 'Não foi possível exportar o diagnóstico do banco.');
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `graphs-db-diagnostic-${new Date().toISOString().slice(0, 10)}.json`;
anchor.click();
URL.revokeObjectURL(url);
};
export const fetchProductionOrders = async (
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 { try {
const params = new URLSearchParams({ const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {});
start: formatDateParam(dateRange.start), if (!response.ok) return { orders: [], counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 } };
end: formatDateParam(dateRange.end) 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);
};
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/dashboard?${params.toString()}`);
if (!response.ok) throw new Error('Não foi possível criar as ordens de produção.');
analyticsCache.clear();
return await response.json();
};
export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => {
const response = await authFetch(`/production-orders/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!response.ok) throw new Error('Não foi possível atualizar o status da ordem.');
analyticsCache.clear();
return await response.json();
};
export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
try {
const response = await authFetch('/cutting-settings');
if (!response.ok) return { familyYields: {}, productOverrides: {} };
return await response.json();
} catch (error) {
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.');
}
return data as CuttingSettings;
};
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: [] };
}
};
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 data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(data?.error || 'Não foi possível salvar a categoria.');
}
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)
});
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
}
};
try {
const response = await authFetch('/supply');
if (!response.ok) return emptySummary;
return await response.json();
} catch (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; if (!response.ok) return null;
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Fetch dashboard analytics failed', error); console.error('Fetch dashboard analytics failed', error);
return null; 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 fetchRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalytics | null> => { export const fetchProductAnalytics = async (dateRange: DateRange): Promise<ProductAnalyticsItem[]> => {
const path = `/analytics/products?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => {
try { try {
const params = new URLSearchParams({ const response = await authFetch(path);
start: formatDateParam(dateRange.start), if (!response.ok) return [];
end: formatDateParam(dateRange.end) return await response.json();
} catch (error) {
console.error('Fetch product analytics failed', error);
return [];
}
}); });
const response = await authFetch(`/analytics/rfm?${params.toString()}`); };
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 exportProductCompositions = async (): Promise<number> => {
const response = await authFetch('/analytics/product-compositions');
const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null;
if (!response.ok) {
throw new Error(data?.error || 'Não foi possível exportar as composições.');
}
const compositions = data?.compositions || [];
const blob = new Blob([JSON.stringify({
exportedAt: new Date().toISOString(),
compositions
}, null, 2)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `composicoes_produtos_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return compositions.length;
};
const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
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; if (!response.ok) return null;
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Fetch RFM analytics failed', error); console.error('Fetch RFM analytics failed', error);
return null; return null;
} }
}, options);
}; };
export const fetchClientAnalytics = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => { 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 { try {
const params = new URLSearchParams({ const response = await authFetch(path);
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const response = await authFetch(`/analytics/clients?${params.toString()}`);
if (!response.ok) return []; if (!response.ok) return [];
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Fetch client analytics failed', error); console.error('Fetch client analytics failed', error);
return []; 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> => { 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 { try {
const params = new URLSearchParams({ const response = await authFetch(path);
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const response = await authFetch(`/analytics/clients/${encodeURIComponent(clientToken)}/details?${params.toString()}`);
if (!response.ok) return null; if (!response.ok) return null;
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Fetch client details analytics failed', error); console.error('Fetch client details analytics failed', error);
return null; return null;
} }
});
};
export const clearAnalyticsCache = () => {
analyticsCache.clear();
}; };
export const fetchCampaigns = async (): Promise<CampaignQueueSummary | null> => { export const fetchCampaigns = async (): Promise<CampaignQueueSummary | null> => {

41
src/displayFormatters.ts Normal file
View 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(' ');
};

View File

@@ -1,22 +1,66 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--color-brand-primary: #9ECAE1; --color-brand-primary: #b8c7d1;
--color-dark-bg: #0a0a0a; --color-brand-contrast: #070707;
--color-dark-bg: #070707;
--color-dark-card: #141414; --color-dark-card: #141414;
--color-dark-header: #141414; --color-dark-header: #101010;
--color-dark-sidebar: #141414; --color-dark-sidebar: #111111;
--color-dark-border: #222222; --color-dark-border: #282828;
--color-dark-input: #1a1a1a; --color-dark-input: #1c1c1c;
--color-dark-text: #ededed; --color-dark-text: #f0f0ee;
--color-dark-muted: #888888; --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"; --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
} }
@layer base { @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 { body {
font-family: 'Inter', sans-serif; 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 { ::-webkit-scrollbar {
@@ -29,3 +73,186 @@
border-radius: 3px; 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;
}
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { import {
AlertTriangle,
AtSign, AtSign,
Check, Check,
Copy, Copy,
@@ -8,10 +9,13 @@ import {
Loader2, Loader2,
Mail, Mail,
Plus, Plus,
RefreshCw,
Search, Search,
Shield,
Trash2, Trash2,
User as UserIcon, User as UserIcon,
UserCheck,
UserPlus,
UserX,
X X
} from 'lucide-react'; } from 'lucide-react';
import { createUser, deleteUser as deleteManagedUser, fetchUsers, updateUser } from '../dataService'; import { createUser, deleteUser as deleteManagedUser, fetchUsers, updateUser } from '../dataService';
@@ -31,25 +35,30 @@ const statusOptions: Array<{ key: StatusFilter; label: string }> = [
{ key: 'inactive', label: 'Inativos' } { key: 'inactive', label: 'Inativos' }
]; ];
const formatDateTime = (value: string) => { const roleLabels: Record<string, string> = {
const date = new Date(value); admin: 'Admin',
if (!value || Number.isNaN(date.getTime())) return '-'; supervisor: 'Supervisor',
producao: 'Produção',
return new Intl.DateTimeFormat('pt-BR', { operacao: 'Operação',
day: '2-digit', vendedor: 'Vendedor',
month: 'short', financeiro: 'Financeiro',
year: 'numeric', user: 'Usuário'
hour: '2-digit',
minute: '2-digit'
}).format(date);
}; };
const inputWrapClassName = 'flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary';
const inputClassName = 'min-w-0 flex-1 bg-transparent text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted';
const getInitials = (name: string) => { const getInitials = (name: string) => {
const parts = name.trim().split(/\s+/).filter(Boolean); const parts = name.trim().split(/\s+/).filter(Boolean);
if (!parts.length) return '?'; if (!parts.length) return '?';
return parts.slice(0, 2).map((part) => part[0]).join('').toUpperCase(); return parts.slice(0, 2).map((part) => part[0]).join('').toUpperCase();
}; };
const getUserRoleLabel = (user: ManagedUser) => {
const role = String((user as ManagedUser & { role?: string }).role || 'user').toLowerCase();
return roleLabels[role] || role;
};
const AdminUsers = () => { const AdminUsers = () => {
const [users, setUsers] = useState<ManagedUser[]>([]); const [users, setUsers] = useState<ManagedUser[]>([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -87,6 +96,8 @@ const AdminUsers = () => {
}); });
}, [searchTerm, statusFilter, users]); }, [searchTerm, statusFilter, users]);
const hasFilters = searchTerm.trim() !== '' || statusFilter !== 'all';
const loadUsers = async () => { const loadUsers = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -119,6 +130,11 @@ const AdminUsers = () => {
resetForm(); resetForm();
}; };
const clearFilters = () => {
setSearchTerm('');
setStatusFilter('all');
};
const openCreateModal = () => { const openCreateModal = () => {
resetForm(); resetForm();
setIsModalOpen(true); setIsModalOpen(true);
@@ -251,57 +267,99 @@ const AdminUsers = () => {
? 'border-amber-500/30 bg-amber-500/10 text-amber-200' ? 'border-amber-500/30 bg-amber-500/10 text-amber-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'; : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300';
const statusCounts: Record<StatusFilter, number> = {
all: users.length,
active: activeCount,
inactive: inactiveCount
};
const stats = [
{ label: 'Total', value: users.length, icon: UserIcon, tone: 'text-dark-text', detail: 'acessos cadastrados' },
{ label: 'Ativos', value: activeCount, icon: UserCheck, tone: 'text-emerald-300', detail: 'podem entrar agora' },
{ label: 'Inativos', value: inactiveCount, icon: UserX, tone: 'text-red-300', detail: 'bloqueados no login' },
{ label: 'Exibindo', value: filteredUsers.length, icon: Search, tone: 'text-brand-primary', detail: hasFilters ? 'após filtros' : 'sem filtros' }
];
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<header className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <header className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div> <div className="max-w-3xl">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-brand-primary"> <h1 className="text-3xl font-bold text-dark-text">Usuários</h1>
<Shield className="h-4 w-4" /> <p className="mt-2 text-sm leading-6 text-dark-muted">
Super admin Gerencie acessos individuais, senhas iniciais e bloqueios de entrada no painel.
</div> </p>
<h1 className="text-2xl font-bold text-dark-text">Usuários</h1>
<p className="mt-1 text-sm text-dark-muted">Controle quem acessa o painel.</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<button
type="button"
onClick={() => void loadUsers()}
disabled={isLoading}
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Atualizar
</button>
<button <button
type="button" type="button"
onClick={openCreateModal} onClick={openCreateModal}
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-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" /> <UserPlus className="h-4 w-4" />
Novo usuário Novo acesso
</button> </button>
</div> </div>
</header> </header>
<section className="rounded-lg border border-dark-border bg-dark-card"> <section className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="flex flex-col gap-4 border-b border-dark-border p-4 lg:flex-row lg:items-center lg:justify-between"> {stats.map((stat) => {
<div className="grid grid-cols-3 divide-x divide-dark-border overflow-hidden rounded-lg border border-dark-border bg-dark-input lg:w-[360px]"> const Icon = stat.icon;
<div className="px-4 py-3"> return (
<p className="text-xs font-semibold text-dark-muted">Total</p> <div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="mt-1 text-xl font-bold text-dark-text">{users.length}</p> <div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
<p className={`mt-2 text-2xl font-bold ${stat.tone}`}>{stat.value}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{stat.detail}</p>
</div> </div>
<div className="px-4 py-3"> <div className="flex h-10 w-10 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted">
<p className="text-xs font-semibold text-dark-muted">Ativos</p> <Icon className="h-5 w-5" />
<p className="mt-1 text-xl font-bold text-emerald-300">{activeCount}</p>
</div> </div>
<div className="px-4 py-3">
<p className="text-xs font-semibold text-dark-muted">Inativos</p>
<p className="mt-1 text-xl font-bold text-red-300">{inactiveCount}</p>
</div> </div>
</div> </div>
);
})}
</section>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border p-4">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Acessos cadastrados</h2>
<p className="mt-1 text-sm text-dark-muted">Busque, filtre e edite os usuários do painel.</p>
</div>
<div className="flex flex-col gap-3 md:flex-row md:items-center"> <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 md:w-80"> <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" /> <Search className="h-4 w-4 shrink-0 text-dark-muted" />
<input <input
type="search" type="search"
value={searchTerm} value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)} 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" 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" 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>
<div className="grid grid-cols-3 rounded-lg border border-dark-border bg-dark-input p-1"> <div className="grid grid-cols-3 rounded-lg border border-dark-border bg-dark-input p-1">
@@ -317,63 +375,92 @@ const AdminUsers = () => {
}`} }`}
> >
{option.label} {option.label}
<span className="ml-1 text-xs opacity-70">{statusCounts[option.key]}</span>
</button> </button>
))} ))}
</div> </div>
</div> </div>
</div> </div>
</div>
{isLoading ? ( {isLoading ? (
<div className="flex h-80 items-center justify-center text-brand-primary"> <div className="divide-y divide-dark-border" aria-label="Carregando usuários">
<Loader2 className="h-7 w-7 animate-spin" /> {Array.from({ length: 5 }).map((_, index) => (
<div key={`user-skeleton-${index}`} className="grid grid-cols-[1.4fr_1fr_120px_140px_88px] gap-5 px-5 py-4">
{Array.from({ length: 5 }).map((__, column) => (
<div key={`user-skeleton-${index}-${column}`} className="skeleton h-4" />
))}
</div>
))}
</div> </div>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<div className="flex h-80 flex-col items-center justify-center px-6 text-center"> <div className="flex min-h-[360px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-dark-input text-dark-muted"> <div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-dark-border bg-dark-input text-brand-primary">
<UserIcon className="h-6 w-6" /> <UserPlus className="h-7 w-7" />
</div> </div>
<p className="text-base font-bold text-dark-text">Nenhum usuário cadastrado</p> <p className="text-lg font-bold text-dark-text">Nenhum usuário cadastrado</p>
<p className="mt-1 max-w-sm text-sm text-dark-muted">Crie o primeiro acesso para liberar o painel a outra pessoa.</p> <p className="mt-2 max-w-md text-sm leading-6 text-dark-muted">Crie o primeiro acesso individual para tirar o uso compartilhado do login administrativo.</p>
<button
type="button"
onClick={openCreateModal}
className="mt-5 inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90"
>
<UserPlus className="h-4 w-4" />
Criar primeiro acesso
</button>
</div> </div>
) : filteredUsers.length === 0 ? ( ) : filteredUsers.length === 0 ? (
<div className="flex h-80 flex-col items-center justify-center px-6 text-center"> <div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-dark-input text-dark-muted"> <div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-dark-border bg-dark-input text-dark-muted">
<Search className="h-6 w-6" /> <Search className="h-7 w-7" />
</div> </div>
<p className="text-base font-bold text-dark-text">Nenhum resultado</p> <p className="text-lg font-bold text-dark-text">Nenhum resultado</p>
<p className="mt-1 text-sm text-dark-muted">Ajuste a busca ou limpe os filtros.</p> <p className="mt-2 max-w-md text-sm leading-6 text-dark-muted">Não encontramos usuários com os filtros atuais.</p>
<button
type="button"
onClick={clearFilters}
className="mt-5 inline-flex h-10 cursor-pointer items-center justify-center rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
>
Limpar filtros
</button>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[760px] text-left"> <table className="w-full min-w-[900px] table-fixed text-left">
<thead className="border-b border-dark-border text-xs uppercase tracking-widest text-dark-muted"> <thead className="border-b border-dark-border bg-dark-input/35 text-xs uppercase tracking-widest text-dark-muted">
<tr> <tr>
<th className="px-5 py-3 font-bold">Usuário</th> <th className="w-[34%] px-5 py-3 font-bold">Usuário</th>
<th className="px-5 py-3 font-bold">E-mail</th> <th className="w-[24%] px-5 py-3 font-bold">E-mail</th>
<th className="px-5 py-3 font-bold">Status</th> <th className="w-[16%] px-5 py-3 font-bold">Perfil</th>
<th className="px-5 py-3 text-right font-bold">Criado em</th> <th className="w-[14%] px-5 py-3 font-bold">Status</th>
<th className="px-5 py-3 text-right font-bold">Ações</th> <th className="w-[12%] px-5 py-3 text-right font-bold">Ações</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-dark-border"> <tbody className="divide-y divide-dark-border">
{filteredUsers.map((user) => ( {filteredUsers.map((user) => (
<tr key={user.id} className="text-sm transition-colors hover:bg-dark-input/45"> <tr key={user.id} className="text-sm transition-colors hover:bg-dark-input/45">
<td className="px-5 py-4"> <td className="px-5 py-4">
<div className="flex items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-primary/10 text-sm font-bold text-brand-primary"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-primary/10 text-sm font-bold text-brand-primary">
{getInitials(user.name)} {getInitials(user.name)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-semibold text-dark-text">{user.name}</p> <p className="truncate font-bold text-dark-text">{user.name}</p>
<p className="mt-0.5 text-xs font-semibold text-dark-muted">ID {user.id}</p>
</div> </div>
</div> </div>
</td> </td>
<td className="px-5 py-4"> <td className="px-5 py-4">
<div className="flex min-w-0 items-center gap-2 text-dark-muted"> <div className="flex min-w-0 items-center gap-2 text-dark-muted">
<Mail className="h-4 w-4 shrink-0" /> <Mail className="h-4 w-4 shrink-0" />
<span className="truncate">{user.email}</span> <span className="truncate font-semibold">{user.email}</span>
</div> </div>
</td> </td>
<td className="px-5 py-4">
<span className="inline-flex rounded-full border border-brand-primary/20 bg-brand-primary/10 px-2.5 py-1 text-xs font-bold text-brand-primary">
{getUserRoleLabel(user)}
</span>
</td>
<td className="px-5 py-4"> <td className="px-5 py-4">
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold ${ <span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold ${
user.isActive ? 'bg-emerald-500/10 text-emerald-300' : 'bg-red-500/10 text-red-300' user.isActive ? 'bg-emerald-500/10 text-emerald-300' : 'bg-red-500/10 text-red-300'
@@ -382,13 +469,12 @@ const AdminUsers = () => {
{user.isActive ? 'Ativo' : 'Inativo'} {user.isActive ? 'Ativo' : 'Inativo'}
</span> </span>
</td> </td>
<td className="px-5 py-4 text-right text-dark-muted">{formatDateTime(user.createdAt)}</td>
<td className="px-5 py-4"> <td className="px-5 py-4">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
type="button" type="button"
onClick={() => openEditModal(user)} onClick={() => openEditModal(user)}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary/50 hover:text-brand-primary" className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary/50 hover:text-brand-primary"
aria-label={`Editar ${user.name}`} aria-label={`Editar ${user.name}`}
title="Editar" title="Editar"
> >
@@ -400,7 +486,7 @@ const AdminUsers = () => {
setNotice(null); setNotice(null);
setUserToDelete(user); setUserToDelete(user);
}} }}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300" className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300"
aria-label={`Excluir ${user.name}`} aria-label={`Excluir ${user.name}`}
title="Excluir" title="Excluir"
> >
@@ -417,15 +503,20 @@ const AdminUsers = () => {
</section> </section>
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6"> <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/70 px-4 py-6">
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-dark-border bg-dark-card shadow-2xl"> <div className="w-full max-w-2xl overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl">
<div className="flex items-start justify-between border-b border-dark-border px-5 py-4"> <div className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
<div 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> <div>
<h2 className="text-lg font-bold text-dark-text">{editingUser ? 'Editar usuário' : 'Novo usuário'}</h2> <h2 className="text-lg font-bold text-dark-text">{editingUser ? 'Editar usuário' : 'Novo acesso'}</h2>
<p className="mt-1 text-sm text-dark-muted"> <p className="mt-1 text-sm leading-5 text-dark-muted">
{editingUser ? 'Atualize dados, status e senha de acesso.' : 'Crie um acesso individual para o painel.'} {editingUser ? 'Atualize identificação, status e senha.' : 'Crie uma credencial individual para o painel.'}
</p> </p>
</div> </div>
</div>
<button <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}
@@ -437,16 +528,23 @@ const AdminUsers = () => {
</button> </button>
</div> </div>
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser} className="space-y-4 p-5"> <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"> <label className="block">
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">Nome</span> <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"> <div className={inputWrapClassName}>
<UserIcon className="h-4 w-4 shrink-0 text-dark-muted" /> <UserIcon className="h-4 w-4 shrink-0 text-dark-muted" />
<input <input
type="text" type="text"
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted" className={inputClassName}
placeholder="Nome completo" placeholder="Nome completo"
required required
autoFocus autoFocus
@@ -456,59 +554,88 @@ const AdminUsers = () => {
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">E-mail</span> <span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">E-mail</span>
<div className="flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary"> <div className={inputWrapClassName}>
<AtSign className="h-4 w-4 shrink-0 text-dark-muted" /> <AtSign className="h-4 w-4 shrink-0 text-dark-muted" />
<input <input
type="email" type="email"
value={email} value={email}
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setEmail(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted" className={inputClassName}
placeholder="usuario@empresa.com" placeholder="usuario@empresa.com"
required required
/> />
</div> </div>
</label> </label>
</div>
</section>
<div> <section>
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">{editingUser ? 'Senha' : 'Senha inicial'}</span> <div className="mb-3">
<div className="grid grid-cols-2 rounded-lg border border-dark-border bg-dark-input p-1"> <h3 className="text-sm font-bold text-dark-text">{editingUser ? 'Senha' : 'Senha inicial'}</h3>
<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 <button
type="button" type="button"
onClick={() => setPasswordMode('auto')} 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'}`} 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'
}`}
> >
{editingUser ? 'Manter' : 'Gerar'} <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>
<button <button
type="button" type="button"
onClick={() => setPasswordMode('manual')} 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'}`} 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'
}`}
> >
{editingUser ? 'Alterar' : 'Definir'} <span className="flex items-center gap-2 text-sm font-bold">
<Edit3 className="h-4 w-4" />
{editingUser ? 'Alterar senha' : 'Definir senha'}
</span>
<span className="mt-1 block text-xs font-semibold opacity-80">
Use quando a senha será combinada fora do sistema.
</span>
</button> </button>
</div> </div>
{passwordMode === 'manual' && ( {passwordMode === 'manual' && (
<div className="mt-3 flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary"> <div className="mt-3">
<div className={inputWrapClassName}>
<KeyRound className="h-4 w-4 shrink-0 text-dark-muted" /> <KeyRound className="h-4 w-4 shrink-0 text-dark-muted" />
<input <input
type="password" type="password"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted" className={inputClassName}
placeholder="Mínimo de 6 caracteres" placeholder="Mínimo de 6 caracteres"
minLength={6} minLength={6}
required required
/> />
</div> </div>
)}
</div> </div>
)}
</section>
{editingUser && ( {editingUser && (
<label className="flex cursor-pointer items-center justify-between rounded-lg border border-dark-border bg-dark-input px-3 py-3"> <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> <div>
<span className="block text-sm font-semibold text-dark-text">Usuário ativo</span> <span className="block text-sm font-bold 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> <span className="mt-0.5 block text-xs font-semibold text-dark-muted">Usuários inativos não conseguem entrar.</span>
</div> </div>
<input <input
type="checkbox" type="checkbox"
@@ -520,20 +647,20 @@ const AdminUsers = () => {
)} )}
{notice && ( {notice && (
<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}`}>
<div className="flex items-start gap-2"> <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" />} {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"> <div className="min-w-0 flex-1">
<p>{notice.text}</p> <p>{notice.text}</p>
{notice.temporaryPassword && ( {notice.temporaryPassword && (
<div className="mt-3 flex gap-2"> <div className="mt-3 flex flex-col gap-2 sm:flex-row">
<code className="min-w-0 flex-1 truncate rounded-md bg-black/25 px-3 py-2 text-amber-100"> <code className="min-w-0 flex-1 truncate rounded-lg bg-black/25 px-3 py-2 text-amber-100">
{notice.temporaryPassword} {notice.temporaryPassword}
</code> </code>
<button <button
type="button" type="button"
onClick={copyTemporaryPassword} 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" 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" /> <Copy className="h-3.5 w-3.5" />
{copyLabel} {copyLabel}
@@ -544,23 +671,24 @@ const AdminUsers = () => {
</div> </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 <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}
disabled={isSubmitting} disabled={isSubmitting}
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-semibold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50" className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
> >
Cancelar Cancelar
</button> </button>
<button <button
type="submit" type="submit"
disabled={isSubmitting} 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" />} {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{editingUser ? 'Salvar alterações' : 'Criar usuário'} {editingUser ? 'Salvar alterações' : 'Criar acesso'}
</button> </button>
</div> </div>
</form> </form>
@@ -570,19 +698,24 @@ const AdminUsers = () => {
{userToDelete && ( {userToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6">
<div className="w-full max-w-md overflow-hidden rounded-lg border border-dark-border bg-dark-card shadow-2xl"> <div className="w-full max-w-md overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl">
<div className="border-b border-dark-border px-5 py-4"> <div className="flex items-start gap-3 border-b border-dark-border px-5 py-4">
<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> <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> <p className="mt-1 text-sm leading-5 text-dark-muted">Esta ação remove o acesso imediatamente.</p>
</div>
</div> </div>
<div className="space-y-4 p-5"> <div className="space-y-4 p-5">
<div className="rounded-lg border border-dark-border bg-dark-input p-4"> <div className="rounded-xl border border-dark-border bg-dark-input p-4">
<p className="font-semibold text-dark-text">{userToDelete.name}</p> <p className="font-bold text-dark-text">{userToDelete.name}</p>
<p className="mt-1 text-sm text-dark-muted">{userToDelete.email}</p> <p className="mt-1 text-sm font-semibold text-dark-muted">{userToDelete.email}</p>
</div> </div>
{notice?.tone === 'error' && ( {notice?.tone === 'error' && (
<div className={`rounded-lg border px-3 py-3 text-sm ${noticeClass}`}> <div className={`rounded-xl border px-3 py-3 text-sm font-semibold ${noticeClass}`}>
{notice.text} {notice.text}
</div> </div>
)} )}
@@ -596,7 +729,7 @@ const AdminUsers = () => {
setNotice(null); setNotice(null);
}} }}
disabled={isSubmitting} disabled={isSubmitting}
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-semibold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50" className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
> >
Cancelar Cancelar
</button> </button>

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; 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 type { CampaignGroup, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CampaignStatus } from '../types';
import { fetchCampaignPreview, fetchCampaigns, processCampaignsNow, retryCampaignGroup } from '../dataService'; import { fetchCampaignPreview, fetchCampaigns, processCampaignsNow, retryCampaignGroup } from '../dataService';
@@ -11,12 +12,21 @@ const statusLabels: Record<CampaignStatus, string> = {
skipped: 'Ignorada' skipped: 'Ignorada'
}; };
const statusStyles: Record<CampaignStatus, string> = { type StatusVisual = {
pending: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', accent: string;
processing: 'bg-blue-500/10 text-blue-400 border-blue-500/20', bg: string;
sent: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', border: string;
failed: 'bg-red-500/10 text-red-400 border-red-500/20', text: string;
skipped: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20' };
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> = { const statusIcons: Record<CampaignStatus, typeof Clock> = {
@@ -34,12 +44,75 @@ const formatDate = (value?: string | null) => {
const formatDelta = (value: number) => `${value} un.`; 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 Campaigns = () => {
const [summary, setSummary] = useState<CampaignQueueSummary | null>(null); const [summary, setSummary] = useState<CampaignQueueSummary | null>(null);
const [preview, setPreview] = useState<CampaignPreview | null>(null); const [preview, setPreview] = useState<CampaignPreview | null>(null);
const [processResult, setProcessResult] = useState<CampaignProcessSummary | null>(null); const [processResult, setProcessResult] = useState<CampaignProcessSummary | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [previewFilter, setPreviewFilter] = useState<PreviewFilter>('all');
const loadCampaigns = async () => { const loadCampaigns = async () => {
setIsLoading(true); setIsLoading(true);
@@ -72,6 +145,62 @@ const Campaigns = () => {
return counts; return counts;
}, [summary]); }, [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 () => { const handleProcessNow = async () => {
setIsProcessing(true); setIsProcessing(true);
const result = await processCampaignsNow(); const result = await processCampaignsNow();
@@ -106,7 +235,7 @@ const Campaigns = () => {
<button <button
onClick={handleProcessNow} onClick={handleProcessNow}
disabled={isProcessing || !preview?.readyProducts.length} 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} /> <Send size={16} />
Processar agora Processar agora
@@ -114,21 +243,35 @@ const Campaigns = () => {
</div> </div>
</div> </div>
<RefreshStatus isRefreshing={isRefreshing || isProcessing} label={isProcessing ? 'Processando campanhas' : 'Atualizando campanhas'} />
{processResult && ( {processResult && (
<div className="bg-dark-card border border-dark-border rounded-2xl p-4 text-sm text-dark-muted"> <div className="bg-dark-card border border-dark-border rounded-2xl p-3">
Resultado: {processResult.claimed} itens processados, {processResult.sentGroups} grupos enviados, {processResult.failedGroups} falhas, {processResult.pendingBelowThresholdGroups} abaixo do limite. <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> </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"> <div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{Object.entries(groupedCounts).map(([status, count]) => { {statusSummary.map(({ status, label, count, Icon, style }) => {
const typedStatus = status as CampaignStatus;
const Icon = statusIcons[typedStatus];
return ( return (
<div key={status} className="bg-dark-card border border-dark-border rounded-2xl p-4"> <div key={status} className="bg-dark-card border border-dark-border rounded-2xl p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{statusLabels[typedStatus]}</span> <span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{label}</span>
<Icon className="w-4 h-4 text-brand-primary" /> <Icon className="w-4 h-4" style={{ color: style.accent }} />
</div> </div>
<p className="text-2xl font-bold text-dark-text">{count}</p> <p className="text-2xl font-bold text-dark-text">{count}</p>
</div> </div>
@@ -157,36 +300,85 @@ const Campaigns = () => {
<p className="text-xl font-bold text-dark-text">{summary?.threshold ?? preview?.threshold ?? 100}</p> <p className="text-xl font-bold text-dark-text">{summary?.threshold ?? preview?.threshold ?? 100}</p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="flex flex-wrap gap-2">
{(preview?.readyProducts || []).map(product => ( {previewFilterOptions.map(option => (
<div key={product.baseProduct} className="flex justify-between gap-4 border border-emerald-500/20 bg-emerald-500/5 rounded-xl p-3"> <button
<span className="font-semibold text-dark-text">{product.baseProduct}</span> key={option.key}
<span className="text-emerald-400 font-bold">{formatDelta(product.total_delta)}</span> type="button"
</div> 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>
))}
<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>
</div> </div>
<div className="bg-dark-card border border-dark-border rounded-2xl p-6"> <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="mb-4 flex items-center gap-2">
<div className="space-y-2"> <Users className="w-5 h-5 text-brand-primary" />
{(preview?.customersPreview || []).map(customer => ( <h2 className="text-lg font-bold text-dark-text">Top clientes da campanha</h2>
<div key={`${customer.fone}-${customer.nome}`} className="flex items-center justify-between gap-4 border border-dark-border rounded-xl p-3"> </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"> <div className="min-w-0">
<p className="font-semibold text-dark-text truncate">{customer.nome}</p> <p className="font-semibold text-dark-text truncate">{customer.nome}</p>
<p className="text-xs text-dark-muted">{customer.fone}</p> <p className="text-xs text-dark-muted">{customer.fone}</p>
</div> </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> </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> </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>} {group.lastError && <p className="text-xs text-red-400 mt-1 max-w-md truncate">{group.lastError}</p>}
</td> </td>
<td className="px-6 py-3"> <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" /> <Icon className="w-3.5 h-3.5" />
{statusLabels[group.status]} {statusLabels[group.status]}
</span> </span>
@@ -247,6 +442,8 @@ const Campaigns = () => {
)} )}
</div> </div>
</div> </div>
)}
</div>
); );
}; };

View File

@@ -1,10 +1,28 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom'; import { useParams, Link, useOutletContext } from 'react-router-dom';
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronLeft, ChevronRight, ShoppingBag, ReceiptText } from 'lucide-react'; import { User, Tag, Package, DollarSign, Clock, Phone, ChevronDown, ShoppingBag, ReceiptText } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { AreaChart, Area, BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import type { ClientDetailsAnalytics, DateRange } from '../types'; import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus';
import type { ClientDetailsAnalytics, DateRange, OrderData } from '../types';
import { fetchClientDetailsAnalytics } from '../dataService'; 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 = { type CustomTooltipProps = {
active?: boolean; active?: boolean;
@@ -12,6 +30,22 @@ type CustomTooltipProps = {
label?: string; 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) => { const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
return ( return (
@@ -26,6 +60,109 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
return null; 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 ClientDetails = () => {
const { clientToken } = useParams<{ clientToken: string }>(); const { clientToken } = useParams<{ clientToken: string }>();
const decodedClientToken = clientToken ? decodeURIComponent(clientToken) : ''; const decodedClientToken = clientToken ? decodeURIComponent(clientToken) : '';
@@ -37,6 +174,7 @@ const ClientDetails = () => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [ordersPerPage, setOrdersPerPage] = useState(5); const [ordersPerPage, setOrdersPerPage] = useState(5);
const [expandedOrderIds, setExpandedOrderIds] = useState<Set<string>>(() => new Set());
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -76,15 +214,24 @@ const ClientDetails = () => {
const handleDateRangeChange = (range: DateRange) => { const handleDateRangeChange = (range: DateRange) => {
setCurrentPage(1); setCurrentPage(1);
setExpandedOrderIds(new Set());
setDateRange(range); setDateRange(range);
}; };
if (isLoading) { const toggleOrder = (orderId: string) => {
return ( setExpandedOrderIds(current => {
<div className="text-center py-12"> const next = new Set(current);
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando cliente...</p> if (next.has(orderId)) {
</div> next.delete(orderId);
); } else {
next.add(orderId);
}
return next;
});
};
if (isLoading && !details) {
return <ClientDetailsSkeleton />;
} }
if (!details?.hasClient) { if (!details?.hasClient) {
@@ -99,6 +246,10 @@ const ClientDetails = () => {
const { const {
chartData, chartData,
groupedOrders, groupedOrders,
purchaseHours = [],
purchaseHourRangeLabel = 'Últimos 60 dias',
purchaseWeekdayRangeLabel = 'Todo período',
purchaseWeekdays = [],
allTimeOrderCount, allTimeOrderCount,
clientName, clientName,
clientPhone, clientPhone,
@@ -112,15 +263,20 @@ const ClientDetails = () => {
const safeCurrentPage = Math.min(currentPage, totalPages || 1); const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * ordersPerPage; const startIndex = (safeCurrentPage - 1) * ordersPerPage;
const paginatedOrders = groupedOrders.slice(startIndex, startIndex + 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header Area */} {/* Header Area */}
<div className="flex flex-col gap-4"> <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"> <BackButton fallbackTo="/clients" />
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Link>
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -153,6 +309,9 @@ const ClientDetails = () => {
</div> </div>
</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="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 className="bg-dark-card p-5 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
<div> <div>
@@ -193,7 +352,9 @@ const ClientDetails = () => {
</div> </div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm"> <div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<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 ? ( {chartData.length === 0 ? (
<div className="flex h-[320px] items-center justify-center text-sm font-semibold text-zinc-500 dark:text-dark-muted"> <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. Nenhum gasto no período selecionado.
@@ -201,27 +362,144 @@ const ClientDetails = () => {
) : ( ) : (
<div className="h-[320px] w-full"> <div className="h-[320px] w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 80 }}> <AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: isSingleDayRange ? 24 : 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} /> <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 <XAxis
dataKey="date" dataKey="date"
stroke="#888888" stroke={CHART_AXIS_COLOR}
fontSize={10} fontSize={10}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
interval={0} interval={isSingleDayRange ? 2 : 0}
angle={-45} angle={isSingleDayRange ? 0 : -45}
textAnchor="end" textAnchor={isSingleDayRange ? 'middle' : 'end'}
height={80} height={isSingleDayRange ? 24 : 80}
/> />
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(value) => formatCurrency(Number(value))} /> <YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} tickFormatter={(value) => formatCurrency(Number(value))} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#222222' }} /> <Tooltip content={<CustomTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
<Bar dataKey="value" fill="#9ECAE1" radius={[4, 4, 0, 0]} /> <Area
</BarChart> 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> </ResponsiveContainer>
</div> </div>
)} )}
</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 */} {/* Orders List */}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{paginatedOrders.length === 0 ? ( {paginatedOrders.length === 0 ? (
@@ -229,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="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> <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> </div>
) : paginatedOrders.map((group, groupIndex) => ( ) : paginatedOrders.map((group) => {
<div key={groupIndex} className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm"> const isExpanded = expandedOrderIds.has(group.orderId);
<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"> return (
<Tag className="w-4 h-4 text-brand-primary" /> <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} Pedido ID: {group.orderId}
</h2> </h2>
<span className="text-sm font-bold text-brand-primary"> <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">
Total do Pedido: {formatCurrency(group.orderTotal)} <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> </span>
)}
</div>
</div>
</div> </div>
<div className="flex shrink-0 items-center gap-3">
<div className="text-right">
<p className="text-[10px] font-bold uppercase tracking-widest text-zinc-400 dark:text-dark-muted">Total</p>
<p className="text-sm font-bold text-brand-primary">{formatCurrency(group.orderTotal)}</p>
</div>
<span className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-500 transition-colors dark:border-dark-border dark:bg-dark-card dark:text-dark-muted">
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
</span>
</div>
</button>
{isExpanded && (
<div className="divide-y divide-zinc-100 dark:divide-dark-border"> <div className="divide-y divide-zinc-100 dark:divide-dark-border">
{group.items.map((order, index) => ( {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 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-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
@@ -271,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> <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>
</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>
<div className="text-right shrink-0"> <div className="text-right shrink-0">
@@ -278,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> <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> </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"> <PaginationControls
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-dark-muted"> totalItems={groupedOrders.length}
<span>Mostrar</span> currentPage={safeCurrentPage}
<select totalPages={totalPages}
value={ordersPerPage} pageSize={ordersPerPage}
onChange={(event) => { pageSizeOptions={[5, 10, 20, 50]}
setOrdersPerPage(Number(event.target.value)); itemLabel="pedidos"
pageSizeLabel="pedidos por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + ordersPerPage, groupedOrders.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setOrdersPerPage(pageSize);
setCurrentPage(1); 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" className="px-6 py-4 border border-zinc-200 dark:border-dark-border rounded-2xl bg-white dark:bg-dark-card"
> />
<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>
</div> </div>
</div> </div>
); );

View File

@@ -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 { Link, useOutletContext } from 'react-router-dom';
import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react'; import { Search, ChevronRight, Filter, X } from 'lucide-react';
import type { ClientAnalyticsItem, DateRange, RfmAnalytics, RfmClient } from '../types'; import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { exportToCSV, fetchClientAnalytics, fetchRfmAnalytics } from '../dataService'; import PaginationControls from '../components/PaginationControls';
import DateRangePicker from '../components/DateRangePicker'; 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 type { ClientSortOption, ClientSummary } from '../analytics/clients';
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
const clientTypeStyles: Record<string, string> = { type ClientTypeStyle = {
'Sem análise': 'border-zinc-600/30 bg-zinc-600/15 text-zinc-300', bg: string;
'Campeão': 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', border: string;
'Potencial Leal': 'border-sky-500/30 bg-sky-500/15 text-sky-300', text: string;
'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 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 = [ const clientTypes = [
'Sem análise', 'Sem análise',
'Campeão', '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 Clients = () => {
const { dateRange, setDateRange } = useOutletContext<{ const { dateRange, setDateRange, themeMode } = useOutletContext<{
dateRange: DateRange, dateRange: DateRange,
setDateRange: (range: DateRange) => void setDateRange: (range: DateRange) => void,
themeMode?: ThemeMode
}>(); }>();
const clientTypeStyles = themeMode === 'offwhite' ? offwhiteClientTypeStyles : darkClientTypeStyles;
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<ClientSortOption>('recent'); const [sortBy, setSortBy] = useState<ClientSortOption>('recent');
const [clientTypeFilter, setClientTypeFilter] = useState('all'); const [clientTypeFilter, setClientTypeFilter] = useState('all');
const [rfmAnalytics, setRfmAnalytics] = useState<RfmAnalytics | null>(null); const [metadataFilters, setMetadataFilters] = useState<ClientMetadataFilters>(emptyClientFilters);
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>([]); 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 // Pagination state
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@@ -83,15 +251,35 @@ const Clients = () => {
let isMounted = true; let isMounted = true;
const loadClients = async () => { 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) { if (isMounted) {
setClientAnalytics(clientsData); if (cachedOptions) setFilterOptions(cachedOptions);
setRfmAnalytics(null); 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) { if (isMounted) {
setRfmAnalytics(rfmData); setRfmAnalytics(rfmData);
setIsRfmLoading(false);
} }
}; };
@@ -100,23 +288,79 @@ const Clients = () => {
return () => { return () => {
isMounted = false; 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 allClientsData = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase(); const normalizedSearch = searchTerm.trim().toLowerCase();
const rfmByCustomerKey = new Map((rfmAnalytics?.clients || []).map(client => [client.customerKey, client])); const rfmByCustomerKey = new Map((rfmAnalytics?.clients || []).map(client => [client.customerKey, client]));
const clients = clientAnalytics.map((client): ClientSummary => { 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 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 { return {
customerKey: client.customerKey, customerKey: client.customerKey ?? rawClient.customer_key ?? '',
clientToken: client.clientToken, clientToken: client.clientToken ?? rawClient.client_token ?? '',
name: client.name, name: client.name,
phone: client.phone, phone: client.phone,
totalSpent: client.totalSpent, totalSpent,
averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0, averageTicket: orderCount ? totalSpent / orderCount : 0,
totalItems: client.quantityPurchased, totalItems,
orderCount: client.orderCount, orderCount,
lastPurchase: client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : 0, lastPurchase: lastPurchaseDate ? new Date(lastPurchaseDate).getTime() : 0,
clientType: rfmClient ? (backendSegmentToClientType[rfmClient.segmentKey] || rfmClient.segmentLabel) : 'Sem análise', clientType: rfmClient ? (backendSegmentToClientType[rfmClient.segmentKey] || rfmClient.segmentLabel) : 'Sem análise',
rfmScore: rfmClient?.rfmScore || '000', rfmScore: rfmClient?.rfmScore || '000',
rfmPriority: rfmClient ? getRfmPriority(rfmClient) : 0 rfmPriority: rfmClient ? getRfmPriority(rfmClient) : 0
@@ -147,7 +391,8 @@ const Clients = () => {
// Pagination logic // Pagination logic
const totalPages = Math.ceil(clientsData.length / itemsPerPage); 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 paginatedData = clientsData.slice(startIndex, startIndex + itemsPerPage);
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
@@ -158,37 +403,208 @@ const Clients = () => {
return new Intl.NumberFormat('pt-BR').format(value); 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) => { const selectClientType = (type: string) => {
setClientTypeFilter(current => current === type ? 'all' : type); setClientTypeFilter(current => current === type ? 'all' : type);
setCurrentPage(1); 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 ( return (
<div className="space-y-6"> <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> <div>
<h1 className="text-2xl font-bold mb-2 text-zinc-900 dark:text-dark-text">Clientes</h1> <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>
<div className="flex flex-col sm:flex-row flex-wrap gap-3 items-center justify-start xl:justify-end"> <div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<DateRangePicker <div className="relative w-full sm:w-72">
dateRange={dateRange} <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" />
onChange={(range) => { <input
setDateRange(range); type="text"
placeholder="Buscar cliente..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1); setCurrentPage(1);
}} }}
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>
<div className="relative"> <div ref={filterMenuRef} className="relative w-full sm:w-auto">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-4 h-4" /> <button
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'
}`}
>
<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 <select
value={sortBy} value={sortBy}
onChange={(e) => { onChange={(e) => {
setSortBy(e.target.value as ClientSortOption); setSortBy(e.target.value as ClientSortOption);
setCurrentPage(1); 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" className={`${filterSelectClassName} mt-1`}
> >
<option value="recent">Mais Recentes</option> <option value="recent">Mais Recentes</option>
<option value="spent_desc">Maior Gasto</option> <option value="spent_desc">Maior Gasto</option>
@@ -199,76 +615,119 @@ const Clients = () => {
<option value="items_desc">Mais Produtos</option> <option value="items_desc">Mais Produtos</option>
<option value="items_asc">Menos Produtos</option> <option value="items_asc">Menos Produtos</option>
</select> </select>
</div> </label>
<div className="relative w-full sm:w-auto"> <label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" /> De
<input <input
type="text" type="date"
placeholder="Buscar cliente..." value={formatDateParam(dateRange.start)}
value={searchTerm} onChange={(event) => updateDateStart(event.target.value)}
onChange={(e) => { className={`${filterSelectClassName} mt-1`}
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"
/> />
</div> </label>
<button <label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
onClick={() => { Até
const exportData = clientsData.map(client => ({ <input
'Nome do Cliente': client.name, type="date"
'Telefone/WhatsApp': client.phone || 'N/A', value={formatDateParam(dateRange.end)}
'Tipo de Cliente': client.clientType, onChange={(event) => updateDateEnd(event.target.value)}
'RFV': client.rfmScore, className={`${filterSelectClassName} mt-1`}
'Total Gasto (R$)': client.totalSpent.toFixed(2).replace('.', ','), />
'Ticket Médio (R$)': client.averageTicket.toFixed(2).replace('.', ','), </label>
'Produtos Comprados': client.totalItems,
'Total de Pedidos': client.orderCount, <label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
'Última Compra': new Date(client.lastPurchase).toLocaleDateString('pt-BR') Marketplace
})); <select
exportToCSV(exportData, `clientes_${new Date().toISOString().split('T')[0]}.csv`); value={metadataFilters.marketplace}
}} onChange={(event) => updateMetadataFilter('marketplace', event.target.value)}
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" className={`${filterSelectClassName} mt-1`}
title="Exportar para CSV"
> >
<Download size={16} className="text-brand-primary" /> <option value="">Todos</option>
<span className="hidden sm:inline">Exportar</span> {marketplaceOptions.map(marketplace => (
</button> <option key={marketplace} value={marketplace}>{marketplace}</option>
</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 => (
<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]
}`}
>
<span>{type}</span>
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[10px]">{clientTypeCounts[type] || 0}</span>
</button>
))} ))}
</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> </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"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <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"> <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">
@@ -284,7 +743,10 @@ const Clients = () => {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border"> <tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{paginatedData.map((client, index) => ( {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"> <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"> <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"> <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">
@@ -293,11 +755,25 @@ const Clients = () => {
</td> </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 font-semibold text-zinc-900 dark:text-dark-text">{client.name}</td>
<td className="px-6 py-2.5"> <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}`}> {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} {client.clientType}
</span> </span>
)}
</td> </td>
<td className="px-6 py-2.5"> <td className="px-6 py-2.5">
{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"> <div className="flex items-center gap-1">
{client.rfmScore.split('').map((score, scoreIndex) => ( {client.rfmScore.split('').map((score, scoreIndex) => (
<span <span
@@ -308,6 +784,7 @@ const Clients = () => {
</span> </span>
))} ))}
</div> </div>
)}
</td> </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-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> <td className="px-6 py-2.5 text-zinc-700 dark:text-dark-text font-semibold">{formatCurrency(client.averageTicket)}</td>
@@ -324,54 +801,113 @@ const Clients = () => {
</Link> </Link>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination Controls */} <PaginationControls
<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"> totalItems={clientsData.length}
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-dark-muted"> currentPage={safeCurrentPage}
<span>Mostrar</span> totalPages={totalPages}
<select pageSize={itemsPerPage}
value={itemsPerPage} pageSizeOptions={[10, 20, 50, 100]}
onChange={(e) => { itemLabel="clientes"
setItemsPerPage(Number(e.target.value)); pageSizeLabel="itens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, clientsData.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1); 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" />
> </div>
<option value={10}>10</option> )}
<option value={20}>20</option>
<option value={50}>50</option> {isPatternLoading ? (
<option value={100}>100</option> <ClientPurchasePatternSkeleton />
</select> ) : (
<span>itens por página</span> <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 className="flex items-center gap-4 text-sm"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<span className="text-zinc-500 dark:text-dark-muted"> <div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
Mostrando {clientsData.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + itemsPerPage, clientsData.length)} de {clientsData.length} clientes <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> </span>
<div className="flex gap-1"> </div>
<button {hasWeekdayPattern ? (
onClick={() => setCurrentPage(p => Math.max(1, p - 1))} <div className="h-56">
disabled={currentPage === 1} <ResponsiveContainer width="100%" height="100%">
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" <BarChart data={purchasePattern?.purchaseWeekdays || []} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
> <CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
<ChevronLeft className="w-5 h-5" /> <XAxis dataKey="label" stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
</button> <YAxis allowDecimals={false} stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
<button <Tooltip content={<PatternTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} <Bar dataKey="value" radius={[4, 4, 0, 0]} animationDuration={850} animationEasing="ease-out">
disabled={currentPage === totalPages || totalPages === 0} {(purchasePattern?.purchaseWeekdays || []).map(day => (
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" <Cell key={`clients-weekday-${day.label}`} fill={WEEKDAY_BAR_COLOR} fillOpacity={0.62} stroke={WEEKDAY_BAR_COLOR} strokeOpacity={0.9} strokeWidth={1.25} />
> ))}
<ChevronRight className="w-5 h-5" /> </Bar>
</button> </BarChart>
</div> </ResponsiveContainer>
</div> </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>
</div> </div>
</section>
)}
</div> </div>
); );
}; };

1342
src/pages/Cutting.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,207 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useOutletContext, useNavigate } from 'react-router-dom'; import { useOutletContext, useNavigate } from 'react-router-dom';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Line } from 'recharts';
import { DollarSign, Loader2, ShoppingCart, TrendingUp } from 'lucide-react'; import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import RefreshStatus from '../components/RefreshStatus';
import type { DashboardAnalytics, OrderData, DateRange } from '../types'; import type { DashboardAnalytics, OrderData, DateRange } from '../types';
import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard'; import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
import { fetchDashboardAnalytics } from '../dataService'; import { fetchDashboardAnalytics } from '../dataService';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getDateBucketKey, getMovingAverageWindow, type DateBucket } from '../chartUtils';
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); 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 = { type ChartTooltipPayload = {
value: number; value: number;
name?: string; name?: string;
color?: string; color?: string;
stroke?: string;
dataKey?: string;
payload?: { payload?: {
fill?: string; fill?: string;
}; };
@@ -25,24 +212,83 @@ type CustomTooltipProps = {
payload?: ChartTooltipPayload[]; payload?: ChartTooltipPayload[];
label?: string; label?: string;
isCurrency?: boolean; isCurrency?: boolean;
valueLabel?: string;
}; };
const CustomTooltip = ({ active, payload, label, isCurrency }: CustomTooltipProps) => { const CustomTooltip = ({ active, payload, label, isCurrency, valueLabel }: CustomTooltipProps) => {
if (active && payload && payload.length) { 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 displayLabel = label || payload[0].name;
const value = isCurrency ? formatCurrency(payload[0].value) : payload[0].value; const value = isCurrency ? formatCurrency(payload[0].value) : payload[0].value;
const valueLabel = isCurrency ? 'Receita:' : 'Vendas:'; const displayValueLabel = valueLabel || (isCurrency ? 'Receita:' : 'Vendas:');
return ( 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="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> </div>
); );
} }
return null; 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 Dashboard = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval } = useOutletContext<{ const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval } = useOutletContext<{
@@ -54,10 +300,14 @@ const Dashboard = () => {
}>(); }>();
const [serverMetrics, setServerMetrics] = useState<DashboardAnalytics | null>(null); const [serverMetrics, setServerMetrics] = useState<DashboardAnalytics | null>(null);
const [isMetricsLoading, setIsMetricsLoading] = useState(true); 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); setIsMetricsLoading(true);
const metrics = await fetchDashboardAnalytics(range); const metrics = await fetchDashboardAnalytics(range, options);
setServerMetrics(metrics); setServerMetrics(metrics);
setIsMetricsLoading(false); setIsMetricsLoading(false);
}, []); }, []);
@@ -72,20 +322,215 @@ const Dashboard = () => {
if (refreshInterval === 0) return; if (refreshInterval === 0) return;
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
void loadDashboardMetrics(dateRange); void loadDashboardMetrics(dateRange, { force: true });
}, refreshInterval); }, refreshInterval);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [dateRange, loadDashboardMetrics, refreshInterval]); }, [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); if (serverMetrics) return applyDashboardColors(serverMetrics);
return buildDashboardMetrics(ordersData, dateRange); return buildDashboardMetrics(ordersData, dateRange);
}, [dateRange, ordersData, serverMetrics]); }, [dateRange, ordersData, serverMetrics]);
const handleManualRefresh = () => { const chartRevenueBySeller = useMemo(
void loadDashboardMetrics(dateRange); () => 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, { force: true });
};
const shouldShowSkeleton = isMetricsLoading && !serverMetrics;
const isRefreshing = isMetricsLoading && Boolean(serverMetrics);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -103,13 +548,12 @@ const Dashboard = () => {
/> />
</div> </div>
{isMetricsLoading && ( <RefreshStatus isRefreshing={isRefreshing} />
<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>
)}
{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="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="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<div className="flex justify-between items-start"> <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> <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> <h3 className="text-3xl font-bold text-dark-text">{formatCurrency(totalRevenue)}</h3>
</div> </div>
<div className="p-3 bg-emerald-500/10 rounded-xl"> <div className="rounded-xl border p-3" style={kpiIconStyle('#52DFA0')}>
<DollarSign className="w-6 h-6 text-emerald-500" /> <DollarSign className="w-6 h-6" />
</div> </div>
</div> </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> <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> <h3 className="text-3xl font-bold text-dark-text">{totalOrders}</h3>
</div> </div>
<div className="p-3 bg-blue-500/10 rounded-xl"> <div className="rounded-xl border p-3" style={kpiIconStyle('#82B8FF')}>
<ShoppingCart className="w-6 h-6 text-blue-500" /> <ShoppingCart className="w-6 h-6" />
</div> </div>
</div> </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> <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> <h3 className="text-3xl font-bold text-dark-text">{formatCurrency(averageOrderValue)}</h3>
</div> </div>
<div className="p-3 bg-purple-500/10 rounded-xl"> <div className="rounded-xl border p-3" style={kpiIconStyle('#B992FF')}>
<TrendingUp className="w-6 h-6 text-purple-500" /> <TrendingUp className="w-6 h-6" />
</div> </div>
</div> </div>
</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="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"> <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> <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"> <div className="h-80 w-full flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={salesByProduct} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> <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 <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} tick={false}
/> />
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} /> <YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#222222' }} /> <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' }}> <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) => ( {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> </Bar>
</BarChart> </BarChart>
@@ -184,12 +877,20 @@ const Dashboard = () => {
<div className="h-80 w-full flex items-center justify-center"> <div className="h-80 w-full flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <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) => ( {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> </Pie>
<Tooltip content={<CustomTooltip isCurrency={true} />} cursor={{ fill: '#222222' }} /> <Tooltip content={<CustomTooltip isCurrency={true} />} cursor={{ fill: CHART_CURSOR_COLOR }} />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -204,6 +905,8 @@ const Dashboard = () => {
</div> </div>
</div> </div>
</div> </div>
)}
</div>
); );
}; };

View File

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

View File

@@ -0,0 +1,439 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { AlertTriangle, CheckCircle2, Eye, PackageSearch, Pencil, Search } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import SkuPlanningModal from '../components/SkuPlanningModal';
import { classifyCutFamily, CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting';
import { fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
import { getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
import { parseProductName } from '../productParsing';
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
type IssueKey = 'review_type' | 'missing_cut_family' | 'missing_color' | 'missing_size' | 'missing_yield';
type IssueFilter = 'all' | IssueKey;
type PlanningIssueRow = ProductAnalyticsItem & {
productType: ProductTypeKey;
color: string;
size: string;
familyKey: CutFamilyKey | '';
familyLabel: string;
issues: IssueKey[];
priorityScore: number;
hasOverride: boolean;
};
const issueLabels: Record<IssueKey, string> = {
review_type: 'Tipo',
missing_cut_family: 'Família',
missing_color: 'Cor',
missing_size: 'Tamanho',
missing_yield: 'Rendimento'
};
const issueHelp: Record<IssueKey, string> = {
review_type: 'O classificador não conseguiu identificar o tipo do SKU.',
missing_cut_family: 'SKU de vestuário não caiu em uma família de corte confiável.',
missing_color: 'SKU de vestuário não tem cor clara para planejamento.',
missing_size: 'SKU de vestuário não tem tamanho claro para planejamento.',
missing_yield: 'A família existe, mas ainda falta rendimento em unidades por rolo.'
};
const issueFilters: Array<{ value: IssueFilter; label: string }> = [
{ value: 'all', label: 'Todos' },
{ value: 'review_type', label: issueLabels.review_type },
{ value: 'missing_cut_family', label: issueLabels.missing_cut_family },
{ value: 'missing_color', label: issueLabels.missing_color },
{ value: 'missing_size', label: issueLabels.missing_size },
{ value: 'missing_yield', label: issueLabels.missing_yield }
];
const familyLabels = new Map<CutFamilyKey, string>([
...CUT_FAMILY_RULES.map(rule => [rule.key, `${rule.materialLabel} · ${rule.label}`] as const),
['OUTROS', 'Sem regra']
]);
const formatNumber = (value: number, maximumFractionDigits = 0) => (
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
);
const formatCurrency = (value: number) => (
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
);
const buildIssues = (
product: ProductAnalyticsItem,
settings: CuttingSettings
): PlanningIssueRow => {
const override = settings.productOverrides[product.id];
const metadata = parseProductName(product.name);
const productType = resolveProductType(product.name, override);
const familyKey = override?.familyKey || classifyCutFamily(metadata.baseName).key;
const color = override?.color || metadata.color;
const size = (override?.size || metadata.size).toUpperCase();
const issues: IssueKey[] = [];
if (productType === 'unknown') issues.push('review_type');
if (productType === 'finished_apparel') {
if (familyKey === 'OUTROS') issues.push('missing_cut_family');
if (!color) issues.push('missing_color');
if (!size) issues.push('missing_size');
if (familyKey !== 'OUTROS' && !settings.familyYields[familyKey]) issues.push('missing_yield');
}
return {
...product,
productType,
color,
size,
familyKey,
familyLabel: familyLabels.get(familyKey) || '-',
issues,
priorityScore: (product.quantitySold * 2) + (product.revenue / 100),
hasOverride: Boolean(override)
};
};
const PlanningIssuesSkeleton = () => (
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm" aria-label="Carregando dados pendentes">
<div className="border-b border-dark-border p-4">
<div className="grid grid-cols-[120px_1.4fr_150px_170px_130px_130px_120px] gap-5">
{[0, 1, 2, 3, 4, 5, 6].map(item => <div key={item} className="skeleton h-3" />)}
</div>
</div>
<div className="divide-y divide-dark-border">
{[0, 1, 2, 3, 4, 5].map(row => (
<div key={row} className="grid grid-cols-[120px_1.4fr_150px_170px_130px_130px_120px] gap-5 px-6 py-4">
{[0, 1, 2, 3, 4, 5, 6].map(item => <div key={item} className="skeleton h-4" />)}
</div>
))}
</div>
</div>
);
const PlanningIssues = () => {
const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void
}>();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [settings, setSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [issueFilter, setIssueFilter] = useState<IssueFilter>('all');
const [editingProduct, setEditingProduct] = useState<PlanningIssueRow | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
useEffect(() => {
let isMounted = true;
const loadData = async () => {
setIsLoading(true);
const [productData, planningSettings] = await Promise.all([
fetchProductAnalytics(dateRange),
fetchCuttingSettings()
]);
if (isMounted) {
setProducts(productData);
setSettings(planningSettings);
setIsLoading(false);
}
};
void loadData();
return () => {
isMounted = false;
};
}, [dateRange]);
const rows = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
return products
.map(product => buildIssues(product, settings))
.filter(row => row.issues.length > 0)
.filter(row => issueFilter === 'all' || row.issues.includes(issueFilter))
.filter(row => {
if (!normalizedSearch) return true;
const typeLabel = getProductTypeConfig(row.productType).label.toLowerCase();
return (
row.id.toLowerCase().includes(normalizedSearch) ||
row.name.toLowerCase().includes(normalizedSearch) ||
row.color.toLowerCase().includes(normalizedSearch) ||
row.size.toLowerCase().includes(normalizedSearch) ||
row.familyLabel.toLowerCase().includes(normalizedSearch) ||
typeLabel.includes(normalizedSearch)
);
})
.sort((a, b) => {
if (b.issues.length !== a.issues.length) return b.issues.length - a.issues.length;
return b.priorityScore - a.priorityScore;
});
}, [issueFilter, products, searchTerm, settings]);
const issueCounts = useMemo(() => {
const counts = new Map<IssueKey, number>();
products
.map(product => buildIssues(product, settings))
.forEach(row => row.issues.forEach(issue => counts.set(issue, (counts.get(issue) || 0) + 1)));
return counts;
}, [products, settings]);
const saveProductOverride = async (productId: string, override: CutProductOverride | null) => {
const productOverrides = { ...settings.productOverrides };
if (override) {
productOverrides[productId] = override;
} else {
delete productOverrides[productId];
}
const nextSettings = { ...settings, productOverrides };
setIsSaving(true);
try {
const savedSettings = await saveCuttingSettings(nextSettings);
setSettings(savedSettings);
setEditingProduct(null);
} finally {
setIsSaving(false);
}
};
const totalPages = Math.ceil(rows.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedRows = rows.slice(startIndex, startIndex + itemsPerPage);
const isRefreshing = isLoading && products.length > 0;
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
<div>
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Dados Pendentes</h1>
<p className="font-medium text-zinc-500 dark:text-dark-muted">
Fila principal para revisar tipo, cor, tamanho, família e rendimento dos SKUs.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center 2xl:justify-end">
<Link
to="/registrations"
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
>
<PackageSearch className="h-4 w-4 text-brand-primary" />
Cadastros
</Link>
<DateRangePicker
dateRange={dateRange}
onChange={(range) => {
setDateRange(range);
setCurrentPage(1);
}}
/>
</div>
</div>
<RefreshStatus isRefreshing={isRefreshing} />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 xl:grid-cols-5">
{issueFilters.filter(option => option.value !== 'all').map(option => {
const count = issueCounts.get(option.value as IssueKey) || 0;
return (
<button
key={option.value}
type="button"
onClick={() => {
setIssueFilter(option.value);
setCurrentPage(1);
}}
className={`rounded-2xl border p-4 text-left transition-colors cursor-pointer ${
issueFilter === option.value
? 'border-brand-primary/45 bg-brand-primary/10'
: 'border-dark-border bg-dark-card hover:border-brand-primary/35'
}`}
title={issueHelp[option.value as IssueKey]}
>
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{option.label}</span>
<AlertTriangle className={count ? 'h-4 w-4 text-amber-300' : 'h-4 w-4 text-emerald-300'} />
</div>
<div className="mt-3 text-2xl font-bold text-dark-text">{formatNumber(count)}</div>
</button>
);
})}
</div>
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[minmax(360px,1fr)_auto] xl:items-center">
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<div className="relative w-full md:w-96">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
<input
type="text"
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.target.value);
setCurrentPage(1);
}}
placeholder="Buscar SKU, produto, cor, tipo..."
className="h-10 w-full rounded-xl border border-dark-border bg-dark-input pl-10 pr-3 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
/>
</div>
<select
value={issueFilter}
onChange={(event) => {
setIssueFilter(event.target.value as IssueFilter);
setCurrentPage(1);
}}
className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
>
{issueFilters.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="text-sm font-semibold text-dark-muted">
{formatNumber(rows.length)} SKUs pendentes
</div>
</div>
{isLoading && !products.length ? (
<PlanningIssuesSkeleton />
) : rows.length ? (
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[1180px] table-fixed text-left text-sm">
<colgroup>
<col className="w-[120px]" />
<col className="w-[380px]" />
<col className="w-[150px]" />
<col className="w-[180px]" />
<col className="w-[140px]" />
<col className="w-[140px]" />
<col className="w-[120px]" />
<col className="w-[120px]" />
</colgroup>
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">SKU</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Produto</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Tipo</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Pendências</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Família</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Vendido</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Receita</th>
<th className="px-4 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{paginatedRows.map(row => (
<tr key={row.id} className="transition-colors hover:bg-dark-input/50">
<td className="px-6 py-2.5 font-mono text-[11px] text-dark-muted">#{row.id}</td>
<td className="max-w-0 px-6 py-2.5">
<div className="truncate font-semibold text-dark-text" title={row.name}>{row.name}</div>
<div className="mt-1 flex min-w-0 items-center gap-2 text-[10px] font-semibold text-dark-muted">
<span className="truncate">Cor: {row.color || '-'}</span>
<span>·</span>
<span className="truncate">Tam.: {row.size || '-'}</span>
{row.hasOverride && <span className="rounded-full border border-brand-primary/25 bg-brand-primary/10 px-2 py-0.5 text-brand-primary">manual</span>}
</div>
</td>
<td className="px-6 py-2.5">
<ProductTypeBadge type={row.productType} />
</td>
<td className="px-6 py-2.5">
<div className="flex flex-wrap gap-1.5">
{row.issues.map(issue => (
<span
key={issue}
className="inline-flex rounded-full border border-amber-400/25 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
title={issueHelp[issue]}
>
{issueLabels[issue]}
</span>
))}
</div>
</td>
<td className="px-6 py-2.5 text-xs font-bold text-dark-text">{row.familyLabel}</td>
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-dark-text">{formatNumber(row.quantitySold)} un.</td>
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-brand-primary">{formatCurrency(row.revenue)}</td>
<td className="px-4 py-2.5 text-right">
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditingProduct(row)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border cursor-pointer"
title={`Editar planejamento do SKU ${row.id}`}
aria-label={`Editar planejamento do SKU ${row.id}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<Link
to={`/products/${row.id}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80 cursor-pointer"
title={`Ver SKU ${row.id}`}
aria-label={`Ver SKU ${row.id}`}
>
<Eye className="h-3.5 w-3.5" />
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<PaginationControls
totalItems={rows.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="SKUs"
pageSizeLabel="por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, rows.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(size) => {
setItemsPerPage(size);
setCurrentPage(1);
}}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dark-border bg-dark-card px-6 py-16 text-center shadow-sm">
{products.length ? (
<>
<CheckCircle2 className="h-10 w-10 text-emerald-300" />
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum dado pendente neste filtro.</p>
<p className="mt-1 text-sm font-semibold text-dark-muted">Ajuste o filtro ou o período para revisar outros SKUs.</p>
</>
) : (
<>
<PackageSearch className="h-10 w-10 text-dark-muted" />
<p className="mt-4 text-sm font-bold text-dark-text">Sem produtos no período.</p>
<p className="mt-1 text-sm font-semibold text-dark-muted">Altere o intervalo de datas para carregar a fila.</p>
</>
)}
</div>
)}
{editingProduct && (
<SkuPlanningModal
product={editingProduct}
override={settings.productOverrides[editingProduct.id]}
isSaving={isSaving}
onClose={() => setEditingProduct(null)}
onSave={(override) => saveProductOverride(editingProduct.id, override)}
/>
)}
</div>
);
};
export default PlanningIssues;

View File

@@ -1,58 +1,228 @@
import { useMemo } from 'react'; import { useEffect, useState } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom'; import { useParams, Link, useOutletContext } from 'react-router-dom';
import { ArrowLeft, Package, DollarSign } from 'lucide-react'; import { Package, DollarSign, Pencil, ReceiptText, Warehouse } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import type { OrderData, DateRange } from '../types'; import SkuPlanningModal from '../components/SkuPlanningModal';
import { buildProductDetailsMetrics } from '../analytics/products'; 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 = { type CustomTooltipProps = {
active?: boolean; active?: boolean;
payload?: Array<{ value: number }>; payload?: Array<{
value: number;
dataKey?: string;
payload?: ProductMetricChartPoint;
}>;
label?: string; 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) { 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 ( return (
<div className="bg-[#141414] p-3 rounded-xl shadow-lg border-none"> <div
<p className="font-bold mb-1" style={{ color: '#9ECAE1' }}>{label}</p> className="rounded-xl border p-3 shadow-lg"
<p className="text-[#ededed] m-0">Vendas: {payload[0].value}</p> 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> </div>
); );
} }
return null; 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 ProductDetails = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{ const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange, dateRange: DateRange,
setDateRange: (range: DateRange) => void, setDateRange: (range: DateRange) => void
ordersData: OrderData[],
isDataLoading: boolean,
refreshInterval: number,
setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void
}>(); }>();
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
const [composition, setComposition] = useState<ProductComposition | null>(null);
const [isCompositionLoading, setIsCompositionLoading] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [isPlanningModalOpen, setIsPlanningModalOpen] = useState(false);
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => { useEffect(() => {
return buildProductDetailsMetrics(ordersData, id, dateRange); let isMounted = true;
}, [id, dateRange, ordersData]);
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) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
}; };
if (!productInfo && isDataLoading) { const formatNumber = (value: number) => {
return ( return new Intl.NumberFormat('pt-BR').format(value);
<div className="text-center py-12"> };
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando produto...</p>
</div> const saveProductOverride = async (productId: string, override: CutProductOverride | null) => {
); const productOverrides = { ...planningSettings.productOverrides };
if (override) {
productOverrides[productId] = override;
} else {
delete productOverrides[productId];
} }
if (!productInfo) { 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 (!details?.productInfo) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-zinc-500 dark:text-dark-muted font-medium">Produto não encontrado.</p> <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 ( return (
<div className="space-y-6"> <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 md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-col 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"> <BackButton fallbackTo="/products" />
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Link>
<div className="flex items-center gap-4"> <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"> <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>
<div className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest">ID: #{productInfo.id}</div> <div className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest">ID: #{productInfo.id}</div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{productInfo.name}</h1> <h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{productInfo.name}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ProductTypeBadge type={productType} />
<span className="text-xs font-semibold text-dark-muted">{productTypeConfig.description}</span>
<button
type="button"
onClick={() => setIsPlanningModalOpen(true)}
className="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
title={`Editar planejamento do SKU ${productInfo.id}`}
aria-label={`Editar planejamento do SKU ${productInfo.id}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -86,11 +367,15 @@ const ProductDetails = () => {
onChange={setDateRange} onChange={setDateRange}
/> </div> /> </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 className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
<div> <div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Unidades Vendidas</p> <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>
<div className="p-3 bg-brand-primary/10 rounded-xl text-brand-primary"> <div className="p-3 bg-brand-primary/10 rounded-xl text-brand-primary">
<Package size={24} /> <Package size={24} />
@@ -105,28 +390,284 @@ const ProductDetails = () => {
<DollarSign size={24} /> <DollarSign size={24} />
</div> </div>
</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>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm"> <div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<h3 className="text-lg font-bold mb-8 text-zinc-900 dark:text-dark-text">Volume de Vendas por Data</h3> <div className="mb-5 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div className="h-[400px] w-full"> <div>
<ResponsiveContainer width="100%" height="100%"> <h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Variações do Produto</h3>
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 80 }}> <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>
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} /> </div>
<XAxis <span className="text-xs font-bold uppercase tracking-widest text-zinc-400 dark:text-dark-muted">
dataKey="date" stroke="#888888" fontSize={10} tickLine={false} axisLine={false} {formatNumber(totalOrders)} {totalOrders === 1 ? 'pedido' : 'pedidos'}
interval={0} </span>
angle={-45} </div>
textAnchor="end" {variantBreakdown.length === 0 ? (
height={80} <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
}}
/> />
<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> </div>
</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> </div>
); );
}; };

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

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

View File

@@ -1,35 +1,443 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import { Download, Eye, Filter, Package, PackageCheck, Pencil, Search, TrendingDown, X } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import type { OrderData, DateRange, StockData } from '../types'; import PaginationControls from '../components/PaginationControls';
import { exportToCSV } from '../dataService'; import SkuPlanningModal from '../components/SkuPlanningModal';
import { buildProductsSummary } from '../analytics/products'; import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
import { exportProductCompositions, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification';
import { getPlanningStock } from '../planningStock';
type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales';
type StockStatusFilter = 'all' | StockRisk;
type StockQuantityFilter = 'all' | 'zero' | 'positive' | 'low' | 'high';
type SalesFilter = 'all' | 'sold' | 'not_sold';
type CoverageFilter = 'all' | 'up_to_7' | 'up_to_14' | 'up_to_30' | 'over_30' | 'none';
type ProductTypeFilter = 'all' | ProductTypeKey;
type ProductSortOption = 'sold_desc' | 'sold_asc' | 'stock_priority' | 'revenue_desc' | 'revenue_asc' | 'stock_asc' | 'stock_desc' | 'coverage_asc' | 'coverage_desc' | 'name_asc';
type ProductViewMode = 'sku' | 'group';
type ProductRow = ProductAnalyticsItem & {
dailySales: number;
daysOfCover: number | null;
risk: StockRisk;
riskLabel: string;
baseName: string;
color: string;
size: string;
productIds: string[];
skuCount: number;
colors: string[];
sizes: string[];
topColor: string;
topSize: string;
groupKey: string;
productType: ProductTypeKey;
};
const riskStyles: Record<StockRisk, { label: string; className: string; dotClass: string }> = {
rupture: {
label: 'Sem estoque',
className: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-300',
dotClass: 'bg-zinc-400'
},
critical: {
label: 'Repor agora',
className: 'border-red-400/35 bg-red-400/10 text-red-300',
dotClass: 'bg-red-400'
},
attention: {
label: 'Atenção',
className: 'border-amber-400/35 bg-amber-400/10 text-amber-300',
dotClass: 'bg-amber-400'
},
monitor: {
label: 'Monitorar',
className: 'border-sky-400/35 bg-sky-400/10 text-sky-300',
dotClass: 'bg-sky-400'
},
healthy: {
label: 'Saudável',
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 stockStatusOptions: Array<{ value: StockStatusFilter; label: string }> = [
{ value: 'all', label: 'Todos' },
{ value: 'rupture', label: 'Sem estoque' },
{ value: 'critical', label: 'Repor agora' },
{ value: 'attention', label: 'Atenção' },
{ value: 'monitor', label: 'Monitorar' },
{ value: 'healthy', label: 'Saudável' },
{ value: 'no_sales', label: 'Sem venda' }
];
const stockPriority: Record<StockRisk, number> = {
rupture: 0,
critical: 1,
attention: 2,
monitor: 3,
healthy: 4,
no_sales: 5
};
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 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 classifyStockRisk = (stock: number, dailySales: number): StockRisk => {
if (stock <= 0) return 'rupture';
if (dailySales <= 0) return 'no_sales';
const daysOfCover = stock / dailySales;
if (daysOfCover <= 7) return 'critical';
if (daysOfCover <= 14) return 'attention';
if (daysOfCover <= 30) return 'monitor';
return 'healthy';
};
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 ProductTableSkeleton = () => (
<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 produtos">
<div className="border-b border-zinc-100 p-4 dark:border-dark-border">
<div className="grid grid-cols-[100px_1.5fr_120px_110px_100px_120px_120px_130px_110px] gap-6">
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => (
<div key={`products-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={`products-row-skeleton-${row}`} className="grid grid-cols-[100px_1.5fr_120px_110px_100px_120px_120px_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-4" />
<div className="skeleton h-7 rounded-lg" />
</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 Products = () => { const Products = () => {
const { dateRange, setDateRange, ordersData, stockData } = useOutletContext<{ const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange, dateRange: DateRange,
setDateRange: (range: DateRange) => void, setDateRange: (range: DateRange) => void
ordersData: OrderData[],
stockData: StockData[],
refreshInterval: number,
setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void
}>(); }>();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<ProductSortOption>('sold_desc');
const [stockStatusFilter, setStockStatusFilter] = useState<StockStatusFilter>('all');
const [stockQuantityFilter, setStockQuantityFilter] = useState<StockQuantityFilter>('all');
const [salesFilter, setSalesFilter] = useState<SalesFilter>('all');
const [coverageFilter, setCoverageFilter] = useState<CoverageFilter>('all');
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null);
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
const [isExportingCompositions, setIsExportingCompositions] = useState(false);
// Pagination state
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
const productsData = useMemo(() => { useEffect(() => {
return buildProductsSummary(ordersData, stockData, dateRange, searchTerm); let isMounted = true;
}, [dateRange, searchTerm, ordersData, stockData]);
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 products = await fetchProductAnalytics(dateRange);
if (isMounted) {
setProductAnalytics(products);
setIsLoading(false);
}
};
void loadProducts();
return () => {
isMounted = false;
};
}, [dateRange]);
const saveProductOverride = async (productId: string, override: CutProductOverride | null) => {
const productOverrides = { ...planningSettings.productOverrides };
if (override) {
productOverrides[productId] = override;
} else {
delete productOverrides[productId];
}
const nextSettings = { ...planningSettings, productOverrides };
setIsSavingPlanning(true);
try {
const savedSettings = await saveCuttingSettings(nextSettings);
setPlanningSettings(savedSettings);
setEditingProduct(null);
} finally {
setIsSavingPlanning(false);
}
};
useEffect(() => {
if (!isFilterMenuOpen && !isExportMenuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (!filterMenuRef.current?.contains(event.target as Node)) {
setIsFilterMenuOpen(false);
}
if (!exportMenuRef.current?.contains(event.target as Node)) {
setIsExportMenuOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsFilterMenuOpen(false);
setIsExportMenuOpen(false);
}
};
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isFilterMenuOpen, isExportMenuOpen]);
const productsData = useMemo<ProductRow[]>(() => {
const days = getRangeDays(dateRange);
const normalizedSearch = searchTerm.trim().toLowerCase();
const skuRows = productAnalytics.map(product => {
const dailySales = product.quantitySold / days;
const planningStock = getPlanningStock(product.stock);
const risk = classifyStockRisk(planningStock, dailySales);
const style = riskStyles[risk];
const metadata = parseProductName(product.name);
const productType = resolveProductType(product.name, planningSettings.productOverrides[product.id]);
return {
...product,
dailySales,
daysOfCover: dailySales > 0 ? planningStock / dailySales : null,
risk,
riskLabel: style.label,
baseName: metadata.baseName,
color: metadata.color,
size: metadata.size,
productIds: [product.id],
skuCount: 1,
colors: metadata.color ? [metadata.color] : [],
sizes: metadata.size ? [metadata.size] : [],
topColor: metadata.color || '-',
topSize: metadata.size || '-',
groupKey: encodeProductGroupKey(metadata.baseName),
productType
};
});
const groups = new Map<string, ProductRow[]>();
skuRows.forEach(product => {
const key = product.baseName.toLowerCase();
const group = groups.get(key) || [];
group.push(product);
groups.set(key, group);
});
const groupedRows = Array.from(groups.values()).map(group => {
const first = group[0];
const productType = getDominantProductType(group);
const quantitySold = group.reduce((total, product) => total + product.quantitySold, 0);
const revenue = group.reduce((total, product) => total + product.revenue, 0);
const stock = group.reduce((total, product) => total + product.stock, 0);
const planningStock = group.reduce((total, product) => total + getPlanningStock(product.stock), 0);
const orderLineCount = group.reduce((total, product) => total + product.orderLineCount, 0);
const dailySales = quantitySold / days;
const daysOfCover = dailySales > 0 ? planningStock / dailySales : null;
const risk = classifyStockRisk(planningStock, dailySales);
const style = riskStyles[risk];
const colorTotals = new Map<string, number>();
const sizeTotals = new Map<string, number>();
group.forEach(product => {
if (product.color) {
colorTotals.set(product.color, (colorTotals.get(product.color) || 0) + product.quantitySold);
}
if (product.size) {
sizeTotals.set(product.size, (sizeTotals.get(product.size) || 0) + product.quantitySold);
}
});
const topColor = [...colorTotals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || '-';
const topSize = [...sizeTotals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || '-';
const colors = [...colorTotals.keys()].sort((a, b) => a.localeCompare(b, 'pt-BR'));
const sizes = sortProductSizes([...sizeTotals.keys()]);
return {
...first,
id: first.groupKey,
name: first.baseName,
quantitySold,
revenue,
orderLineCount,
stock,
dailySales,
daysOfCover,
risk,
riskLabel: style.label,
productIds: group.map(product => product.id),
skuCount: group.length,
colors,
sizes,
topColor,
topSize,
productType,
lastPrice: quantitySold > 0 ? revenue / quantitySold : first.lastPrice
};
});
const activeRows = viewMode === 'group' ? groupedRows : skuRows;
const filteredProducts = normalizedSearch
? activeRows.filter(product =>
product.name.toLowerCase().includes(normalizedSearch) ||
product.id.toLowerCase().includes(normalizedSearch) ||
product.productIds.some(id => id.toLowerCase().includes(normalizedSearch)) ||
product.colors.some(color => color.toLowerCase().includes(normalizedSearch)) ||
product.sizes.some(size => size.toLowerCase().includes(normalizedSearch)) ||
getProductTypeConfig(product.productType).label.toLowerCase().includes(normalizedSearch)
)
: activeRows;
const detailedFilteredProducts = filteredProducts.filter(product => {
const matchesStatus = stockStatusFilter === 'all' || product.risk === stockStatusFilter;
const matchesStockQuantity =
stockQuantityFilter === 'all' ||
(stockQuantityFilter === 'zero' && product.stock <= 0) ||
(stockQuantityFilter === 'positive' && product.stock > 0) ||
(stockQuantityFilter === 'low' && product.stock > 0 && product.stock <= 10) ||
(stockQuantityFilter === 'high' && product.stock > 100);
const matchesSales =
salesFilter === 'all' ||
(salesFilter === 'sold' && product.quantitySold > 0) ||
(salesFilter === 'not_sold' && product.quantitySold === 0);
const matchesProductType = productTypeFilter === 'all' || product.productType === productTypeFilter;
const matchesCoverage =
coverageFilter === 'all' ||
(coverageFilter === 'none' && product.daysOfCover === null) ||
(coverageFilter === 'up_to_7' && product.daysOfCover !== null && product.daysOfCover <= 7) ||
(coverageFilter === 'up_to_14' && product.daysOfCover !== null && product.daysOfCover > 7 && product.daysOfCover <= 14) ||
(coverageFilter === 'up_to_30' && product.daysOfCover !== null && product.daysOfCover > 14 && product.daysOfCover <= 30) ||
(coverageFilter === 'over_30' && product.daysOfCover !== null && product.daysOfCover > 30);
return matchesStatus && matchesStockQuantity && matchesSales && matchesProductType && matchesCoverage;
});
return detailedFilteredProducts.sort((a, b) => {
switch (sortBy) {
case 'sold_asc': return a.quantitySold - b.quantitySold;
case 'stock_priority': {
const priorityDiff = stockPriority[a.risk] - stockPriority[b.risk];
if (priorityDiff !== 0) return priorityDiff;
return b.quantitySold - a.quantitySold;
}
case 'revenue_desc': return b.revenue - a.revenue;
case 'revenue_asc': return a.revenue - b.revenue;
case 'stock_asc': return a.stock - b.stock;
case 'stock_desc': return b.stock - a.stock;
case 'coverage_asc': return (a.daysOfCover ?? Number.POSITIVE_INFINITY) - (b.daysOfCover ?? Number.POSITIVE_INFINITY);
case 'coverage_desc': return (b.daysOfCover ?? -1) - (a.daysOfCover ?? -1);
case 'name_asc': return a.name.localeCompare(b.name, 'pt-BR');
case 'sold_desc':
default:
return b.quantitySold - a.quantitySold;
}
});
}, [coverageFilter, dateRange, planningSettings.productOverrides, productAnalytics, productTypeFilter, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter, viewMode]);
const activeFilterCount =
(stockStatusFilter === 'all' ? 0 : 1) +
(stockQuantityFilter === 'all' ? 0 : 1) +
(salesFilter === 'all' ? 0 : 1) +
(productTypeFilter === 'all' ? 0 : 1) +
(coverageFilter === 'all' ? 0 : 1);
const hasActiveFilters = activeFilterCount > 0;
const resetProductFilters = () => {
setStockStatusFilter('all');
setStockQuantityFilter('all');
setSalesFilter('all');
setProductTypeFilter('all');
setCoverageFilter('all');
setCurrentPage(1);
};
// Pagination logic
const totalPages = Math.ceil(productsData.length / itemsPerPage); const totalPages = Math.ceil(productsData.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage; const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedData = productsData.slice(startIndex, startIndex + itemsPerPage); const paginatedData = productsData.slice(startIndex, startIndex + itemsPerPage);
const isRefreshing = isLoading && productAnalytics.length > 0;
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
@@ -37,13 +445,13 @@ const Products = () => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col md:flex-row md: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> <div>
<h1 className="text-2xl font-bold mb-2 text-zinc-900 dark:text-dark-text">Produtos</h1> <h1 className="text-2xl font-bold mb-2 text-zinc-900 dark:text-dark-text">Produtos</h1>
<p className="text-zinc-500 dark:text-dark-muted font-medium">Gestão de catálogo e performance de vendas por item.</p> <p className="text-zinc-500 dark:text-dark-muted font-medium">Performance de vendas, saldo e cobertura por produto.</p>
</div> </div>
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<DateRangePicker <DateRangePicker
dateRange={dateRange} dateRange={dateRange}
onChange={(range) => { onChange={(range) => {
@@ -52,8 +460,99 @@ const Products = () => {
}} }}
/> />
<div ref={exportMenuRef} className="relative">
<button
type="button"
onClick={() => setIsExportMenuOpen(current => !current)}
aria-expanded={isExportMenuOpen}
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="Escolher exportação"
>
<Download size={16} className="text-brand-primary" />
<span>Exportar</span>
</button>
{isExportMenuOpen && (
<div className="absolute right-0 z-20 mt-2 w-56 overflow-hidden rounded-xl border border-dark-border bg-dark-card p-1 shadow-xl">
<button
type="button"
onClick={() => {
const exportData = productsData.map(product => ({
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
'ID Produto': viewMode === 'group' ? product.productIds.join(' | ') : product.id,
'Descrição': product.name,
'SKUs': product.skuCount,
'Cores': product.colors.join(' | '),
'Tamanhos': product.sizes.join(' | '),
'Tipo de produto': getProductTypeConfig(product.productType).label,
'Cor principal': product.topColor,
'Tamanho principal': product.topSize,
'Status': product.riskLabel,
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
'Total Vendido (un.)': product.quantitySold,
'Estoque': product.stock,
'Média Diária': product.dailySales.toFixed(2).replace('.', ','),
'Cobertura': product.daysOfCover === null ? '' : product.daysOfCover.toFixed(1).replace('.', ','),
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
}));
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
setIsExportMenuOpen(false);
}}
className="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer"
>
Produtos (CSV)
</button>
<button
type="button"
disabled={isExportingCompositions}
onClick={async () => {
setIsExportingCompositions(true);
try {
await exportProductCompositions();
setIsExportMenuOpen(false);
} catch (error) {
window.alert(error instanceof Error ? error.message : 'Não foi possível exportar as composições.');
} finally {
setIsExportingCompositions(false);
}
}}
className="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer disabled:cursor-wait disabled:opacity-60"
>
{isExportingCompositions ? 'Preparando…' : 'Composições (JSON)'}
</button>
</div>
)}
</div>
</div>
</div>
<RefreshStatus isRefreshing={isRefreshing} />
<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_auto_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"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" /> <Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
<input <input
type="text" type="text"
placeholder="Buscar por nome ou ID..." placeholder="Buscar por nome ou ID..."
@@ -62,121 +561,310 @@ const Products = () => {
setSearchTerm(e.target.value); setSearchTerm(e.target.value);
setCurrentPage(1); setCurrentPage(1);
}} }}
className="w-full md: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-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> </div>
<div ref={filterMenuRef} className="relative">
<button <button
onClick={() => { type="button"
const exportData = productsData.map(product => ({ onClick={() => setIsFilterMenuOpen(open => !open)}
'ID Produto': product.id, className={`flex h-11 w-full items-center justify-center gap-2 rounded-xl border px-4 text-sm font-medium shadow-sm transition-colors ${
'Descrição': product.name, hasActiveFilters
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','), ? 'cursor-pointer border-brand-primary bg-brand-primary/10 text-brand-primary'
'Total Vendido (un.)': product.totalSold, : 'cursor-pointer border-dark-border bg-dark-input text-dark-text hover:border-brand-primary'
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',') }`}
}));
exportToCSV(exportData, `produtos_${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" /> <Filter className="h-4 w-4" />
<span className="hidden sm:inline">Exportar</span> 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">
Tipo de produto
<select
value={productTypeFilter}
onChange={(event) => {
setProductTypeFilter(event.target.value as ProductTypeFilter);
setCurrentPage(1);
}}
className={`${filterSelectClassName} mt-1`}
>
<option value="all">Todos</option>
{productTypeOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
Status
<select
value={stockStatusFilter}
onChange={(event) => {
setStockStatusFilter(event.target.value as StockStatusFilter);
setCurrentPage(1);
}}
className={`${filterSelectClassName} mt-1`}
>
{stockStatusOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
Estoque
<select
value={stockQuantityFilter}
onChange={(event) => {
setStockQuantityFilter(event.target.value as StockQuantityFilter);
setCurrentPage(1);
}}
className={`${filterSelectClassName} mt-1`}
>
<option value="all">Todos</option>
<option value="zero">Zerado</option>
<option value="positive">Com estoque</option>
<option value="low">Baixo: 1 a 10 un.</option>
<option value="high">Alto: acima de 100 un.</option>
</select>
</label>
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
Vendas
<select
value={salesFilter}
onChange={(event) => {
setSalesFilter(event.target.value as SalesFilter);
setCurrentPage(1);
}}
className={`${filterSelectClassName} mt-1`}
>
<option value="all">Todos</option>
<option value="sold">Vendeu no período</option>
<option value="not_sold">Não vendeu no período</option>
</select>
</label>
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
Cobertura estimada
<select
value={coverageFilter}
onChange={(event) => {
setCoverageFilter(event.target.value as CoverageFilter);
setCurrentPage(1);
}}
className={`${filterSelectClassName} mt-1`}
>
<option value="all">Todos</option>
<option value="up_to_7">0-7 dias</option>
<option value="up_to_14">8-14 dias</option>
<option value="up_to_30">15-30 dias</option>
<option value="over_30">&gt;30 dias</option>
<option value="none">Sem venda</option>
</select>
</label>
</div>
<div className="mt-3 flex items-center justify-between border-t border-dark-border pt-3">
<button
type="button"
onClick={resetProductFilters}
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> </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"> <select
value={sortBy}
onChange={(event) => {
setSortBy(event.target.value as ProductSortOption);
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="sold_desc">Mais vendidos</option>
<option value="sold_asc">Menos vendidos</option>
<option value="stock_priority">Prioridade de estoque</option>
<option value="revenue_desc">Maior receita</option>
<option value="revenue_asc">Menor receita</option>
<option value="stock_asc">Menor estoque</option>
<option value="stock_desc">Maior estoque</option>
<option value="coverage_asc">Menor cobertura</option>
<option value="coverage_desc">Maior cobertura</option>
<option value="name_asc">Nome A-Z</option>
</select>
</div>
{isLoading && productAnalytics.length === 0 ? (
<ProductTableSkeleton />
) : (
<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"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full min-w-[1360px] table-fixed text-left text-sm">
<colgroup>
<col className="w-[120px]" />
<col className="w-[390px]" />
<col className="w-[170px]" />
<col className="w-[150px]" />
<col className="w-[120px]" />
<col className="w-[130px]" />
<col className="w-[130px]" />
<col className="w-[140px]" />
<col className="w-[120px]" />
</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"> <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> <tr>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">ID Produto</th> <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]">Descrição</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">{viewMode === 'group' ? 'Grupo' : '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]">Total Vendido</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Vendido</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]">Estoque</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Média Diária</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Cobertura estimada</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Receita Gerada</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Receita Gerada</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border"> <tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{paginatedData.map((product) => ( {paginatedData.map((product) => {
const style = riskStyles[product.risk];
return (
<tr key={product.id} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group"> <tr key={product.id} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{product.id}</td> <td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">
<td className="px-6 py-2.5"> {viewMode === 'group' ? `${product.skuCount} SKUs` : `#${product.id}`}
<div className="font-semibold text-zinc-900 dark:text-dark-text">{product.name}</div>
<div className="text-[10px] text-zinc-400 dark:text-dark-muted font-medium">Preço Atual: {formatCurrency(product.lastPrice)}</div>
</td> </td>
<td className="px-6 py-2.5"> <td className="max-w-0 px-6 py-2.5">
<div className="flex items-center gap-2"> <div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={product.name}>{product.name}</div>
<Package className="w-3.5 h-3.5 text-zinc-400 dark:text-dark-muted" /> <div className="mt-1 flex min-w-0 items-center gap-2">
<span className="font-bold text-zinc-900 dark:text-dark-text">{product.totalSold} un.</span> <ProductTypeBadge type={product.productType} />
<span className="truncate text-[10px] font-medium text-zinc-400 dark:text-dark-muted">
{viewMode === 'group'
? `Cor principal: ${formatColorLabel(product.topColor)} · Tam. principal: ${product.topSize} · ${product.colors.length} cores · ${product.sizes.length} tamanhos`
: `Preço Atual: ${formatCurrency(product.lastPrice)}`}
</span>
</div> </div>
</td> </td>
<td className="px-6 py-2.5"> <td className="px-6 py-2.5">
<span className="font-bold text-zinc-900 dark:text-dark-text"> <span className={`inline-flex items-center gap-2 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${style.className}`}>
{product.stock} un. <span className={`h-2 w-2 rounded-full ${style.dotClass}`} />
{style.label}
</span> </span>
</td> </td>
<td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(product.revenue)}</td> <td className="px-6 py-2.5 whitespace-nowrap">
<td className="px-6 py-2.5 text-right"> <div className="flex min-w-0 items-center gap-2">
<Link <Package className="h-3.5 w-3.5 shrink-0 text-zinc-400 dark:text-dark-muted" />
to={`/products/${product.id}`} <span className="min-w-0 font-bold tabular-nums text-zinc-900 dark:text-dark-text">{formatNumber(product.quantitySold)} un.</span>
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity bg-brand-primary/10 px-3 py-1.5 rounded-lg cursor-pointer" </div>
</td>
<td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatNumber(product.stock)} un.</td>
<td className="px-6 py-2.5 text-zinc-500 dark:text-dark-muted whitespace-nowrap">{formatNumber(product.dailySales, 2)} un./dia</td>
<td className="px-6 py-2.5 whitespace-nowrap">
<span className="inline-flex items-center gap-1.5 font-bold text-zinc-900 dark:text-dark-text">
<TrendingDown className="h-3.5 w-3.5 text-zinc-400 dark:text-dark-muted" />
{formatDays(product.daysOfCover)}
</span>
</td>
<td className="px-6 py-2.5 text-brand-primary font-bold whitespace-nowrap">{formatCurrency(product.revenue)}</td>
<td className="px-4 py-2.5 text-right">
<div className="flex justify-end gap-2">
{viewMode === 'sku' && (
<button
type="button"
onClick={() => setEditingProduct(product)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border cursor-pointer"
title={`Editar planejamento do SKU ${product.id}`}
aria-label={`Editar planejamento do SKU ${product.id}`}
> >
<TrendingUp className="w-3.5 h-3.5 mr-1.5" /> <Pencil className="h-3.5 w-3.5" />
Ver Gráfico </button>
)}
<Link
to={viewMode === 'group' ? `/products/groups/${product.groupKey}` : `/products/${product.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 ${product.name}` : `Ver SKU ${product.id}`}
aria-label={viewMode === 'group' ? `Ver grupo ${product.name}` : `Ver SKU ${product.id}`}
>
<Eye className="h-3.5 w-3.5" />
</Link> </Link>
</div>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination Controls */} {!productsData.length && (
<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="px-6 py-12 text-center">
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-dark-muted"> <PackageCheck className="mx-auto h-10 w-10 text-dark-muted" />
<span>Mostrar</span> <p className="mt-4 text-sm font-bold text-dark-text">Nenhum produto encontrado.</p>
<select <p className="mt-1 text-sm text-dark-muted">Ajuste a busca ou o período selecionado.</p>
value={itemsPerPage} </div>
onChange={(e) => { )}
setItemsPerPage(Number(e.target.value));
<PaginationControls
totalItems={productsData.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel={viewMode === 'group' ? 'grupos' : 'produtos'}
pageSizeLabel="itens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, productsData.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1); 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>
)}
<div className="flex items-center gap-4 text-sm"> {editingProduct && (
<span className="text-zinc-500 dark:text-dark-muted"> <SkuPlanningModal
Mostrando {productsData.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + itemsPerPage, productsData.length)} de {productsData.length} produtos product={editingProduct}
</span> override={planningSettings.productOverrides[editingProduct.id]}
<div className="flex gap-1"> isSaving={isSavingPlanning}
<button onClose={() => setEditingProduct(null)}
onClick={() => setCurrentPage(p => Math.max(1, p - 1))} onSave={(override) => saveProductOverride(editingProduct.id, override)}
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>
</div>
</div>
</div>
</div>
</div> </div>
); );
}; };

819
src/pages/Registrations.tsx Normal file
View 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 /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)} /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 /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 /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² / /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
View 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;

View File

@@ -1,23 +1,46 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Download, Filter, Loader2, Search, Users } from 'lucide-react'; import { Download, Filter, Search, Users } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; 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'; import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types';
const emptyClients: RfmClient[] = []; const emptyClients: RfmClient[] = [];
const emptySegments: RfmSegment[] = []; const emptySegments: RfmSegment[] = [];
const segmentStyles: Record<string, { accent: string; bg: string; text: string }> = { type SegmentStyle = {
champions: { accent: 'bg-emerald-500', bg: 'bg-emerald-500/10', text: 'text-emerald-400' }, accent: string;
potential_loyalists: { accent: 'bg-sky-500', bg: 'bg-sky-500/10', text: 'text-sky-400' }, bg: string;
new_customers: { accent: 'bg-cyan-500', bg: 'bg-cyan-500/10', text: 'text-cyan-400' }, border: string;
loyal_customers: { accent: 'bg-blue-500', bg: 'bg-blue-500/10', text: 'text-blue-400' }, text: string;
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' }, type ThemeMode = 'dark' | 'offwhite';
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' } 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'>> = [ const rfmSegmentDefinitions: Array<Pick<RfmSegment, 'key' | 'label'>> = [
@@ -36,12 +59,12 @@ const segmentDescriptions: Record<string, string> = {
champions: 'Recentes, recorrentes e valiosos', champions: 'Recentes, recorrentes e valiosos',
potential_loyalists: 'Recentes e em evolução', potential_loyalists: 'Recentes e em evolução',
new_customers: 'Primeira compra recente', new_customers: 'Primeira compra recente',
loyal_customers: 'Bom histórico, compra entre 61 e 180 dias', loyal_customers: 'Bom histórico, compra entre 8 e 15 dias',
need_attention: 'Perfil médio, compra entre 61 e 180 dias', need_attention: 'Perfil médio, compra entre 8 e 15 dias',
about_to_sleep: 'Perfil baixo, compra entre 61 e 180 dias', about_to_sleep: 'Perfil baixo, compra entre 8 e 15 dias',
at_risk: 'Histórico forte, sem compra entre 181 e 365 dias', at_risk: 'Histórico forte, sem compra entre 16 e 29 dias',
hibernating: 'Sem compra entre 181 e 365 dias', hibernating: 'Sem compra entre 16 e 29 dias',
lost: 'Sem compra há 366 dias ou mais' lost: 'Sem compra há 30 dias ou mais'
}; };
const segmentActions: Record<string, string> = { 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); 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 DAY_MS = 24 * 60 * 60 * 1000;
const parseLocalDate = (value: string) => { const parseLocalDate = (value: string) => {
@@ -119,25 +159,143 @@ const ScoreBadge = ({ value }: { value: number }) => (
</span> </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 Rfm = () => {
const { dateRange, setDateRange, refreshInterval, setRefreshInterval } = useOutletContext<{ const { dateRange, setDateRange, refreshInterval, setRefreshInterval, themeMode } = useOutletContext<{
dateRange: DateRange; dateRange: DateRange;
setDateRange: (range: DateRange) => void; setDateRange: (range: DateRange) => void;
refreshInterval: number; refreshInterval: number;
setRefreshInterval: (interval: number) => void; setRefreshInterval: (interval: number) => void;
themeMode?: ThemeMode;
}>(); }>();
const segmentStyles = themeMode === 'offwhite' ? offwhiteSegmentStyles : darkSegmentStyles;
const [analytics, setAnalytics] = useState<RfmAnalytics | null>(null); const initialCachedAnalytics = getCachedRfmAnalytics(dateRange);
const [isLoading, setIsLoading] = useState(true); const [analytics, setAnalytics] = useState<RfmAnalytics | null>(initialCachedAnalytics || null);
const [isLoading, setIsLoading] = useState(!initialCachedAnalytics);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [segmentFilter, setSegmentFilter] = useState('all'); const [segmentFilter, setSegmentFilter] = useState('all');
const [selectedSegmentKey, setSelectedSegmentKey] = useState(''); const [selectedSegmentKey, setSelectedSegmentKey] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20); 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); setIsLoading(true);
const data = await fetchRfmAnalytics(range); const data = await fetchRfmAnalytics(range, undefined, options);
setAnalytics(data); setAnalytics(data);
setIsLoading(false); setIsLoading(false);
}, []); }, []);
@@ -152,12 +310,45 @@ const Rfm = () => {
if (refreshInterval === 0) return; if (refreshInterval === 0) return;
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
void loadRfm(dateRange); void loadRfm(dateRange, { force: true });
}, refreshInterval); }, refreshInterval);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [dateRange, loadRfm, refreshInterval]); }, [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 visibleAnalytics = analytics;
const clients = visibleAnalytics?.clients || emptyClients; const clients = visibleAnalytics?.clients || emptyClients;
const segments = useMemo(() => { const segments = useMemo(() => {
@@ -203,7 +394,8 @@ const Rfm = () => {
}, [clients, searchTerm, segmentFilter]); }, [clients, searchTerm, segmentFilter]);
const totalPages = Math.ceil(filteredClients.length / itemsPerPage); 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 paginatedClients = filteredClients.slice(startIndex, startIndex + itemsPerPage);
const totals = useMemo(() => { const totals = useMemo(() => {
@@ -261,6 +453,8 @@ const Rfm = () => {
exportToCSV(exportData, `rfv_${new Date().toISOString().split('T')[0]}.csv`); exportToCSV(exportData, `rfv_${new Date().toISOString().split('T')[0]}.csv`);
}; };
const shouldShowSkeleton = isLoading && !analytics;
const isRefreshing = isLoading && Boolean(analytics);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -276,17 +470,17 @@ const Rfm = () => {
onChange={setDateRange} onChange={setDateRange}
refreshInterval={refreshInterval} refreshInterval={refreshInterval}
setRefreshInterval={setRefreshInterval} setRefreshInterval={setRefreshInterval}
onManualRefresh={() => void loadRfm(dateRange)} onManualRefresh={() => void loadRfm(dateRange, { force: true })}
/> />
</div> </div>
</div> </div>
{isLoading && ( <RefreshStatus isRefreshing={isRefreshing} />
<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" /> {shouldShowSkeleton ? (
Atualizando RFV <RfmSkeleton />
</div> ) : (
)} <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="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="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
@@ -307,6 +501,7 @@ const Rfm = () => {
</div> </div>
</div> </div>
<div className="space-y-6">
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-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"> <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 className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
@@ -316,7 +511,10 @@ const Rfm = () => {
</div> </div>
<div className="flex items-center gap-2 text-xs font-semibold text-dark-muted"> <div className="flex items-center gap-2 text-xs font-semibold text-dark-muted">
<span>Menor prioridade</span> <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
className="h-2 w-12 rounded-full"
style={{ background: 'linear-gradient(90deg, #FF6B8A, #FFC247, #18D6B5)' }}
/>
<span>Maior prioridade</span> <span>Maior prioridade</span>
</div> </div>
</div> </div>
@@ -343,47 +541,43 @@ const Rfm = () => {
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Rec. {recencyScore}</p> <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="text-xs font-bold text-dark-text">{scoreLabel(recencyScore)}</p>
<p className="mt-1 text-[10px] font-semibold text-dark-muted"> <p className="mt-1 text-[10px] font-semibold text-dark-muted">
{recencyScore === 3 ? '0 a 60 dias' : recencyScore === 2 ? '61 a 180 dias' : '181+ dias'} {recencyScore === 3 ? '0 a 7 dias' : recencyScore === 2 ? '8 a 15 dias' : '16+ dias'}
</p> </p>
</div> </div>
</div> </div>
{[1, 2, 3].map(valueScore => { {[1, 2, 3].map(valueScore => {
const segment = segmentForCell(segments, recencyScore, valueScore); const segment = segmentForCell(segments, recencyScore, valueScore);
const style = segmentStyles[segment?.key || 'lost']; const segmentKey = segment?.key || 'lost';
const isActive = segmentFilter === segment?.key; const style = segmentStyles[segmentKey];
const isActive = segmentFilter === segmentKey;
const intensity = segment?.count ? 'opacity-100' : 'opacity-75'; const intensity = segment?.count ? 'opacity-100' : 'opacity-75';
return ( return (
<button <button
key={`${recencyScore}-${valueScore}`} key={`${recencyScore}-${valueScore}`}
onClick={() => { onClick={() => selectSegment(segmentKey)}
setSegmentFilter(segment?.key || 'all'); className={`group min-h-28 rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 cursor-pointer ${intensity}`}
setSelectedSegmentKey(segment?.key || ''); style={segmentPanelStyle(style, isActive)}
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 className="flex h-full flex-col justify-between gap-3">
<div> <div>
<div className="mb-2 flex items-start justify-between gap-2"> <div className="mb-2 flex items-start justify-between gap-2">
<span className={`h-2 w-8 rounded-full ${style.accent}`} /> <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"> <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} R{recencyScore} P{valueScore}
</span> </span>
</div> </div>
<p className="text-sm font-bold text-dark-text">{segment?.label}</p> <p className="text-sm font-bold text-dark-text">{segment?.label}</p>
<p className="mt-1 truncate text-xs font-medium text-dark-muted"> <p className="mt-1 truncate text-xs font-medium text-dark-muted">
{segmentDescriptions[segment?.key || 'lost']} {segmentDescriptions[segmentKey]}
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Clientes</p> <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> <p className="text-xl font-bold leading-none" style={{ color: style.text }}>{segment?.count || 0}</p>
</div> </div>
<div> <div>
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Receita período</p> <p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Receita período</p>
@@ -408,21 +602,18 @@ const Rfm = () => {
return ( return (
<button <button
key={segment.key} key={segment.key}
onClick={() => { onClick={() => selectSegment(segment.key)}
setSegmentFilter(segment.key);
setSelectedSegmentKey(segment.key);
setCurrentPage(1);
}}
className={`w-full rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${ 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' 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 items-center justify-between gap-3">
<div className="flex min-w-0 items-center 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="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> <span className="truncate text-sm font-bold text-dark-text">{segment.label}</span>
</div> </div>
<span className={`text-sm font-bold ${style.text}`}>{segment.count}</span> <span className="text-sm font-bold" style={{ color: style.text }}>{segment.count}</span>
</div> </div>
<p className="mt-1 text-xs font-semibold text-dark-muted"> <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 {(segmentInsights[segment.key]?.customerPercent || 0).toFixed(1)}% clientes · {formatCurrency(segment.totalRevenue)} no período
@@ -435,12 +626,12 @@ const Rfm = () => {
</div> </div>
{selectedSegment && selectedSegmentInsights && ( {selectedSegment && selectedSegmentInsights && (
<section className={`rounded-2xl border border-dark-border ${selectedSegmentStyle.bg} p-4 shadow-sm`}> <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="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-1 flex items-center gap-2"> <div className="mb-1 flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${selectedSegmentStyle.accent}`} /> <span className="h-2.5 w-2.5 rounded-full" style={segmentDotStyle(selectedSegmentStyle)} />
<p className={`text-xs font-bold uppercase tracking-widest ${selectedSegmentStyle.text}`}>Segmento selecionado</p> <p className="text-xs font-bold uppercase tracking-widest" style={{ color: selectedSegmentStyle.text }}>Segmento selecionado</p>
</div> </div>
<h3 className="text-lg font-bold text-dark-text">{selectedSegment.label}</h3> <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> <p className="mt-1 text-sm font-semibold text-dark-muted">{segmentActions[selectedSegment.key]}</p>
@@ -467,6 +658,7 @@ const Rfm = () => {
</div> </div>
</section> </section>
)} )}
</div>
<section className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden shadow-sm"> <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 lg:flex-row lg:items-center justify-between gap-3 border-b border-dark-border p-4">
@@ -544,8 +736,8 @@ const Rfm = () => {
</Link> </Link>
</td> </td>
<td className="px-6 py-3"> <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="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.accent}`} /> <span className="h-2 w-2 rounded-full" style={segmentDotStyle(style)} />
{client.segmentLabel} {client.segmentLabel}
</span> </span>
</td> </td>
@@ -576,49 +768,27 @@ const Rfm = () => {
</div> </div>
)} )}
<div className="px-6 py-4 border-t border-dark-border flex flex-col sm:flex-row items-center justify-between gap-4"> <PaginationControls
<div className="flex items-center gap-2 text-sm text-dark-muted"> totalItems={filteredClients.length}
<span>Mostrar</span> currentPage={safeCurrentPage}
<select totalPages={totalPages}
value={itemsPerPage} pageSize={itemsPerPage}
onChange={(event) => { pageSizeOptions={[10, 20, 50, 100]}
setItemsPerPage(Number(event.target.value)); itemLabel="clientes"
pageSizeLabel="clientes por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, filteredClients.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1); 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" className="px-6 py-4 border-t border-dark-border"
> />
<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>
</section> </section>
</div> </div>
)}
</div>
); );
}; };

2118
src/pages/Supplies.tsx Normal file

File diff suppressed because it is too large Load Diff

1
src/planningStock.ts Normal file
View File

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

View File

@@ -0,0 +1,177 @@
import { normalizeProductText } from './productParsing.ts';
export type ProductTypeKey =
| 'finished_apparel'
| 'finished_accessory'
| 'raw_material'
| 'packaging'
| 'dtf_input'
| 'dtf_service'
| 'kit_bundle'
| 'service'
| 'machine_part'
| 'equipment'
| 'ignore_from_planning'
| 'unknown';
export type ProductPlanningMode = 'cutting' | 'unit_replenishment' | 'material' | 'service' | 'ignore' | 'review';
export type ProductTypeConfig = {
key: ProductTypeKey;
label: string;
description: string;
planningMode: ProductPlanningMode;
badgeClassName: string;
};
export const productTypeConfigs: Record<ProductTypeKey, ProductTypeConfig> = {
finished_apparel: {
key: 'finished_apparel',
label: 'Vestuário',
description: 'Produto acabado que pode entrar em corte/reposição por SKU.',
planningMode: 'cutting',
badgeClassName: 'border-sky-400/30 bg-sky-400/10 text-sky-300'
},
finished_accessory: {
key: 'finished_accessory',
label: 'Acessório',
description: 'Produto acabado sem regra de malha principal.',
planningMode: 'unit_replenishment',
badgeClassName: 'border-cyan-400/30 bg-cyan-400/10 text-cyan-300'
},
raw_material: {
key: 'raw_material',
label: 'Matéria-prima',
description: 'Entrada de produção, como malha, ribana, tecido, fio ou resíduo.',
planningMode: 'material',
badgeClassName: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
},
packaging: {
key: 'packaging',
label: 'Embalagem',
description: 'Insumo de embalagem controlado por unidade/milheiro.',
planningMode: 'unit_replenishment',
badgeClassName: 'border-amber-400/30 bg-amber-400/10 text-amber-300'
},
dtf_input: {
key: 'dtf_input',
label: 'Insumo DTF',
description: 'Insumo para DTF, como tinta, filme, poliamida ou fluido.',
planningMode: 'material',
badgeClassName: 'border-fuchsia-400/30 bg-fuchsia-400/10 text-fuchsia-300'
},
dtf_service: {
key: 'dtf_service',
label: 'Serviço DTF',
description: 'Impressão, estampa ou personalização vendida como serviço/produto customizado.',
planningMode: 'service',
badgeClassName: 'border-purple-400/30 bg-purple-400/10 text-purple-300'
},
kit_bundle: {
key: 'kit_bundle',
label: 'Kit',
description: 'Bundle marketplace composto por outros SKUs.',
planningMode: 'review',
badgeClassName: 'border-indigo-400/30 bg-indigo-400/10 text-indigo-300'
},
service: {
key: 'service',
label: 'Serviço',
description: 'Frete, transporte, tecelagem, tinturaria ou serviço técnico.',
planningMode: 'service',
badgeClassName: 'border-zinc-400/30 bg-zinc-400/10 text-zinc-300'
},
machine_part: {
key: 'machine_part',
label: 'Peça máquina',
description: 'Peça, limpeza ou manutenção de máquina.',
planningMode: 'unit_replenishment',
badgeClassName: 'border-orange-400/30 bg-orange-400/10 text-orange-300'
},
equipment: {
key: 'equipment',
label: 'Equipamento',
description: 'Máquina ou equipamento permanente.',
planningMode: 'ignore',
badgeClassName: 'border-slate-400/30 bg-slate-400/10 text-slate-300'
},
ignore_from_planning: {
key: 'ignore_from_planning',
label: 'Ignorar',
description: 'Item que não deve dirigir corte, compra ou reposição.',
planningMode: 'ignore',
badgeClassName: 'border-dark-border bg-dark-input text-dark-muted'
},
unknown: {
key: 'unknown',
label: 'Revisar',
description: 'Tipo não identificado automaticamente.',
planningMode: 'review',
badgeClassName: 'border-red-400/30 bg-red-400/10 text-red-300'
}
};
const has = (value: string, pattern: RegExp) => pattern.test(value);
export const classifyProductType = (name: string): ProductTypeKey => {
const normalizedName = normalizeProductText(name)
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toUpperCase();
if (!normalizedName) return 'unknown';
if (has(normalizedName, /\bSALDO ESTOQUE\b/)) return 'ignore_from_planning';
if (has(normalizedName, /^(?:\d+\s+)?(?:MALHA|RIBANA)\b/)) return 'raw_material';
if (has(normalizedName, /\b(?:RETALHO|RESIDUO)\s+(?:DE\s+)?(?:MALHA|MOLETOM)\b/)) return 'raw_material';
if (has(normalizedName, /\bFIO\b.*\bMALHARIA\b/)) return 'raw_material';
if (has(normalizedName, /\b(?:LINHA|FIO) PARA COSTURA\b/)) return 'raw_material';
if (has(normalizedName, /\bATACADOR\b/)) return 'raw_material';
if (has(normalizedName, /\bILHOS\b/)) return 'raw_material';
if (has(normalizedName, /ETIQUETA|TAG|FITA/)) return 'raw_material';
if (has(normalizedName, /\bCREDITO\b/)) return 'ignore_from_planning';
if (has(normalizedName, /\bKIT\b/)) return 'kit_bundle';
if (has(normalizedName, /TRANSPARENTE PP|SACO DE SEGURANCA|MILHEIRO|SACOLA|EMBALAGEM/)) return 'packaging';
if (has(normalizedName, /PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA/)) return 'equipment';
if (has(normalizedName, /FRETE|SERVICO|TRANSPORTE|MOTOTAXI|TECELAGEM|TINTURARIA|MAO DE OBRA/)) return 'service';
if (has(normalizedName, /DUMPER|CABO FLAT|WIPPER|PRIMER|FLUIDO DE LIMPEZA|MISTURADOR|CABECA I3200|CABECA DE IMPRESSAO|SENSOR INFRAVERMELHO|CAPSULA FILTRO|PECA DE MAQUINA/)) return 'machine_part';
if (has(normalizedName, /TINTA DTF|FILME DTF|POLIAMIDA.*DTF|DTF ROLO|PO PARA DTF|BOMBA DE TINTA/)) return 'dtf_input';
if (has(normalizedName, /IMPRESSAO DTF|IMPRESSAO UV|CORRECAO IMPRESSAO|ESTAMPA|PERSONALIZACAO/)) return 'dtf_service';
if (has(normalizedName, /MALHA|RIBANA|FIO|TECIDO|RESIDUO|RETALHO|PIMA|ALGODAO/)) {
if (!has(normalizedName, /CAMISETA|MOLETOM|REGATA|BONE|CHINELO|OVERSIZE|OVER SIZE/)) {
return 'raw_material';
}
}
if (has(normalizedName, /BONE|TRUCKER|\bCAP\b|CHINELO/)) return 'finished_accessory';
if (has(normalizedName, /CAMISETA|MOLETOM|CANGURU|REGATA|INFANTIL|OVER SIZE|OVERSIZE|PROMOCIONAL|VESTUARIO/)) return 'finished_apparel';
if (has(normalizedName, /ETIQUETA|TAG|FITA|LINHA PARA COSTURA|COSTURA/)) return 'raw_material';
return 'unknown';
};
export const getProductTypeConfig = (type: ProductTypeKey) => productTypeConfigs[type] || productTypeConfigs.unknown;
export const productTypeOptions = Object.values(productTypeConfigs)
.filter(config => config.key !== 'ignore_from_planning')
.map(config => ({ value: config.key, label: config.label }));
export const editableProductTypeOptions = Object.values(productTypeConfigs)
.map(config => ({ value: config.key, label: config.label, description: config.description }));
export const resolveProductType = (
name: string,
override?: { productType?: ProductTypeKey | '' } | null
): ProductTypeKey => {
return override?.productType || classifyProductType(name);
};
export const getDominantProductType = <T extends { productType: ProductTypeKey; quantitySold: number }>(rows: T[]) => {
if (!rows.length) return 'unknown';
const totals = rows.reduce<Record<string, number>>((acc, row) => {
acc[row.productType] = (acc[row.productType] || 0) + Math.max(row.quantitySold, 1);
return acc;
}, {});
return (Object.entries(totals).sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown') as ProductTypeKey;
};

26
src/productColors.ts Normal file
View 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
View 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
};
};

View File

@@ -1,3 +1,5 @@
import type { ProductTypeKey } from './productClassification';
export interface OrderData { export interface OrderData {
Nome_Cliente: string; Nome_Cliente: string;
Data_Pedido: string; Data_Pedido: string;
@@ -9,6 +11,12 @@ export interface OrderData {
Recebido_Em?: string; Recebido_Em?: string;
ID_Pedido?: string; ID_Pedido?: string;
Fone_Cliente?: 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 { export interface StockData {
@@ -19,6 +27,341 @@ export interface StockData {
updated_at?: string; 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 { export interface DateRange {
start: Date; start: Date;
end: Date; end: Date;
@@ -50,6 +393,7 @@ export interface CreateUserResult {
export interface DashboardAnalytics { export interface DashboardAnalytics {
totalRevenue: number; totalRevenue: number;
totalOrders: number; totalOrders: number;
orderLineCount?: number;
averageOrderValue: number; averageOrderValue: number;
salesByProduct: Array<{ salesByProduct: Array<{
name: string; name: string;
@@ -61,6 +405,97 @@ export interface DashboardAnalytics {
id: string; id: string;
value: number; 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 { export interface ClientAnalyticsItem {
@@ -74,6 +509,37 @@ export interface ClientAnalyticsItem {
lastPurchaseDate: string; 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 { export interface RfmClient {
customerKey: string; customerKey: string;
clientToken: string; clientToken: string;
@@ -131,6 +597,16 @@ export interface ClientDetailsAnalytics {
date: string; date: string;
value: number; value: number;
}>; }>;
purchaseWeekdays?: Array<{
label: string;
value: number;
}>;
purchaseHours?: Array<{
label: string;
value: number;
}>;
purchaseWeekdayRangeLabel?: string;
purchaseHourRangeLabel?: string;
groupedOrders: GroupedClientOrder[]; groupedOrders: GroupedClientOrder[];
allTimeOrderCount: number; allTimeOrderCount: number;
clientName: string; clientName: string;

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

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

View File

@@ -25,7 +25,7 @@ export default defineConfig(({ command }) => ({
tailwindcss() tailwindcss()
], ],
server: { server: {
port: 3001, port: 3002,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3004', target: 'http://localhost:3004',
@@ -34,7 +34,7 @@ export default defineConfig(({ command }) => ({
} }
}, },
preview: { preview: {
port: 3000, port: 3002,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3004', target: 'http://localhost:3004',