Compare commits
18 Commits
8044771301
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f8b81d2111 | |||
| 1b836567cd | |||
| 39b8faa208 | |||
| 177f7778b9 | |||
| f887dc6af1 | |||
| c344fdc3b8 | |||
| ebef903f3b | |||
| 16d2399de8 | |||
| c3e09d5fd1 | |||
| bae8c575bc | |||
| 45ded53530 | |||
| f41b5ab0f6 | |||
| 6834a77a80 | |||
| 38b12c188f | |||
| 6aefc1b40d | |||
| 7c41a7f799 | |||
| 12cc7a4639 | |||
| 9b2f9016f6 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -80,3 +80,10 @@ chat-migration*/
|
||||
**/chat-migration*/
|
||||
chat-migration*/**
|
||||
**/chat-migration*/**
|
||||
|
||||
venv/
|
||||
venv/**
|
||||
**/venv/*
|
||||
**/venv/**
|
||||
|
||||
inventory-server/data/taxonomy-embeddings.json
|
||||
346
docs/METRICS_AUDIT.md
Normal file
346
docs/METRICS_AUDIT.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# Metrics Calculation Pipeline Audit
|
||||
|
||||
**Date:** 2026-02-07
|
||||
**Scope:** All 6 SQL calculation scripts, custom DB functions, import pipeline, and live data verification
|
||||
|
||||
## Overview
|
||||
|
||||
The metrics pipeline in `inventory-server/scripts/calculate-metrics-new.js` runs 6 SQL scripts sequentially:
|
||||
|
||||
1. `update_daily_snapshots.sql` — Aggregates daily per-product sales/receiving data
|
||||
2. `update_product_metrics.sql` — Calculates the main product_metrics table (KPIs, forecasting, status)
|
||||
3. `update_periodic_metrics.sql` — ABC classification, average lead time
|
||||
4. `calculate_brand_metrics.sql` — Brand-level aggregated metrics
|
||||
5. `calculate_vendor_metrics.sql` — Vendor-level aggregated metrics
|
||||
6. `calculate_category_metrics.sql` — Category-level metrics with hierarchy rollups
|
||||
|
||||
### Database Scale
|
||||
| Table | Row Count |
|
||||
|---|---|
|
||||
| products | 681,912 |
|
||||
| orders | 2,883,982 |
|
||||
| purchase_orders | 256,809 |
|
||||
| receivings | 313,036 |
|
||||
| daily_product_snapshots | 678,312 (601 distinct dates, since 2024-06-01) |
|
||||
| product_metrics | 681,912 |
|
||||
| brand_metrics | 1,789 |
|
||||
| vendor_metrics | 281 |
|
||||
| category_metrics | 610 |
|
||||
|
||||
---
|
||||
|
||||
## Issues Found
|
||||
|
||||
### ISSUE 1: [HIGH] Order status filter is non-functional — numeric codes vs text comparison
|
||||
|
||||
**Files:** `update_daily_snapshots.sql` lines 86-101, `update_product_metrics.sql` lines 89, 178-183
|
||||
**Confirmed by data:** All order statuses are numeric strings ('100', '50', '55', etc.)
|
||||
**Status mappings from:** `docs/prod_registry.class.php`
|
||||
|
||||
**Description:** The SQL filters `COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned')` and `o.status NOT IN ('canceled', 'returned')` are used throughout the pipeline to exclude canceled/returned orders. However, the import pipeline stores order statuses as their **raw numeric codes** from the production MySQL database (e.g., '100', '50', '55', '90', '92'). There are **zero text status values** in the orders table.
|
||||
|
||||
This means these filters **never exclude any rows** — every comparison is `'100' NOT IN ('canceled', 'returned')` which is always true.
|
||||
|
||||
**Actual status distribution (with confirmed meanings):**
|
||||
| Status | Meaning | Count | Negative Qty | Assessment |
|
||||
|---|---|---|---|---|
|
||||
| 100 | shipped | 2,862,792 | 3,352 | Completed — correct to include |
|
||||
| 50 | awaiting_products | 11,109 | 0 | In-progress — not yet shipped |
|
||||
| 55 | shipping_later | 5,689 | 0 | In-progress — not yet shipped |
|
||||
| 56 | shipping_together | 2,863 | 0 | In-progress — not yet shipped |
|
||||
| 90 | awaiting_shipment | 38 | 0 | Near-complete — not yet shipped |
|
||||
| 92 | awaiting_pickup | 71 | 0 | Near-complete — awaiting customer |
|
||||
| 95 | shipped_confirmed | 5 | 0 | Completed — correct to include |
|
||||
| 15 | cancelled | 1 | 0 | Should be excluded |
|
||||
|
||||
**Full status reference (from prod_registry.class.php):**
|
||||
- 0=created, 10=unfinished, **15=cancelled**, 16=combined, 20=placed, 22=placed_incomplete
|
||||
- 30=cancelled_old (historical), 40=awaiting_payment, 50=awaiting_products
|
||||
- 55=shipping_later, 56=shipping_together, 60=ready, 61=flagged
|
||||
- 62=fix_before_pick, 65=manual_picking, 70=in_pt, 80=picked
|
||||
- 90=awaiting_shipment, 91=remote_wait, **92=awaiting_pickup**, 93=fix_before_ship
|
||||
- **95=shipped_confirmed**, **100=shipped**
|
||||
|
||||
**Severity revised to HIGH (from CRITICAL):** Now that we know the actual meanings, no cancelled/refunded orders are being miscounted (only 1 cancelled order exists, status=15). The real concern is twofold:
|
||||
1. **The text-based filter is dead code** — it can never match any row. Either map statuses to text during import (like POs do) or change SQL to use numeric comparisons.
|
||||
2. **~19,775 unfulfilled orders** (statuses 50/55/56/90/92) are counted as completed sales. These are orders in various stages of fulfillment that haven't shipped yet. While most will eventually ship, counting them now inflates current-period metrics. At 0.69% of total orders, the financial impact is modest but the filter should work correctly on principle.
|
||||
|
||||
**Note:** PO statuses ARE properly mapped to text ('canceled', 'done', etc.) in the import pipeline. Only order statuses are numeric.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 2: [CRITICAL] Daily Snapshots use current stock instead of historical EOD stock
|
||||
|
||||
**File:** `update_daily_snapshots.sql`, lines 126-135, 173
|
||||
**Confirmed by data:** Top product (pid 666925) shows `eod_stock_quantity = 0` for ALL dates even though it sold 28 units on Jan 28 (clearly had stock then)
|
||||
|
||||
**Description:** The `CurrentStock` CTE reads `stock_quantity` directly from the `products` table at query execution time. When the script processes historical dates (today minus 1-4 days), it writes **today's stock** as if it were the end-of-day stock for those past dates.
|
||||
|
||||
**Cascading impact on product_metrics:**
|
||||
- `avg_stock_units_30d` / `avg_stock_cost_30d` — Wrong averages
|
||||
- `stockout_days_30d` — Undercounts (only based on current stock state, not historical)
|
||||
- `stockout_rate_30d`, `service_level_30d`, `fill_rate_30d` — All derived from wrong stockout data
|
||||
- `gmroi_30d` — Wrong denominator (avg stock cost)
|
||||
- `stockturn_30d` — Wrong denominator (avg stock units)
|
||||
- `sell_through_30d` — Affected by stock level inaccuracy
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 3: [CRITICAL] Snapshot coverage is 0.17% — most products have no snapshot data
|
||||
|
||||
**Confirmed by data:** 678,312 snapshot rows across 601 dates = ~1,128 products/day out of 681,912 total
|
||||
|
||||
**Description:** The daily snapshots script only creates rows for products with sales or receiving activity on that date (`ProductsWithActivity` CTE, line 136). This means:
|
||||
- 91.1% of products (621,221) have NULL `sales_30d` — they had no orders in the last 30 days so no snapshot rows exist
|
||||
- `AVG(eod_stock_quantity)` averages only across days with activity, not 30 days
|
||||
- `stockout_days_30d` only counts stockout days where there was ALSO some activity
|
||||
- A product out of stock with zero sales gets zero stockout_days even though it was stocked out
|
||||
|
||||
This is by design (to avoid creating 681K rows/day) but means stock-related metrics are systematically biased.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 4: [HIGH] `costeach` fallback to 50% of price in import pipeline
|
||||
|
||||
**File:** `inventory-server/scripts/import/orders.js` (line ~573)
|
||||
|
||||
**Description:** When the MySQL `order_costs` table has no record for an order item, `costeach` defaults to `price * 0.5`. There is **no flag** in the PostgreSQL data to distinguish actual costs from estimated ones.
|
||||
|
||||
**Data impact:** 385,545 products (56.5%) have `current_cost_price = 0` AND `current_landing_cost_price = 0`. For these products, the COGS calculation in daily_snapshots falls through the chain:
|
||||
1. `o.costeach` — May be the 50% estimate from import
|
||||
2. `get_weighted_avg_cost()` — Returns NULL if no receivings exist
|
||||
3. `p.landing_cost_price` — Always NULL (hardcoded in import)
|
||||
4. `p.cost_price` — 0 for 56.5% of products
|
||||
|
||||
Only 27 products have zero COGS with positive sales, meaning the `costeach` field is doing its job for products that sell, but the 50% fallback means margins for those products are estimates, not actuals.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 5: [HIGH] `landing_cost_price` is always NULL
|
||||
|
||||
**File:** `inventory-server/scripts/import/products.js` (line ~175)
|
||||
|
||||
**Description:** The import explicitly sets `landing_cost_price = NULL` for all products. The daily_snapshots COGS calculation uses it as a fallback: `COALESCE(o.costeach, get_weighted_avg_cost(...), p.landing_cost_price, p.cost_price)`. Since it's always NULL, this fallback step is useless and the chain jumps straight to `cost_price`.
|
||||
|
||||
The `product_metrics` field `current_landing_cost_price` is populated as `COALESCE(p.landing_cost_price, p.cost_price, 0.00)`, so it equals `cost_price` for all products. Any UI showing "landing cost" is actually just showing `cost_price`.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 6: [HIGH] Vendor lead time is drastically wrong — missing supplier_id join
|
||||
|
||||
**File:** `calculate_vendor_metrics.sql`, lines 62-82
|
||||
**Confirmed by data:** Vendor-level lead times are 2-10x higher than product-level lead times
|
||||
|
||||
**Description:** The vendor metrics lead time joins POs to receivings only by `pid`:
|
||||
```sql
|
||||
LEFT JOIN public.receivings r ON r.pid = po.pid
|
||||
```
|
||||
But the periodic metrics lead time correctly matches supplier:
|
||||
```sql
|
||||
JOIN public.receivings r ON r.pid = po.pid AND r.supplier_id = po.supplier_id
|
||||
```
|
||||
|
||||
Without supplier matching, a PO for product X from Vendor A can match a receiving of product X from Vendor B, creating inflated/wrong lead times.
|
||||
|
||||
**Measured discrepancies:**
|
||||
| Vendor | Vendor Metrics Lead Time | Avg Product Lead Time |
|
||||
|---|---|---|
|
||||
| doodlebug design inc. | 66 days | 14 days |
|
||||
| Notions | 55 days | 4 days |
|
||||
| Simple Stories | 59 days | 27 days |
|
||||
| Ranger Industries | 31 days | 5 days |
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 7: [MEDIUM] Net revenue does not subtract returns
|
||||
|
||||
**File:** `update_daily_snapshots.sql`, line 184
|
||||
|
||||
**Description:** `net_revenue = gross_revenue - discounts`. Standard accounting: `net_revenue = gross_revenue - discounts - returns`. The `returns_revenue` is calculated separately but not deducted.
|
||||
|
||||
**Data impact:** There are 3,352 orders with negative quantities (returns), totaling -5,499 units. These returns are tracked in `returns_revenue` but not reflected in `net_revenue`, which means all downstream revenue-based metrics are slightly overstated.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 8: [MEDIUM] Lifetime revenue subquery references wrong table columns
|
||||
|
||||
**File:** `update_product_metrics.sql`, lines 323-329
|
||||
|
||||
**Description:** The lifetime revenue estimation fallback queries:
|
||||
```sql
|
||||
SELECT revenue_7d / NULLIF(sales_7d, 0)
|
||||
FROM daily_product_snapshots
|
||||
WHERE pid = ci.pid AND sales_7d > 0
|
||||
```
|
||||
But `daily_product_snapshots` does NOT have `revenue_7d` or `sales_7d` columns — those exist in `product_metrics`. This subquery either errors silently or returns NULL. The effect is that the estimation always falls back to `current_price * total_sold`.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 9: [MEDIUM] Brand/Vendor metrics COGS filter inflates margins
|
||||
|
||||
**Files:** `calculate_brand_metrics.sql` lines 31, `calculate_vendor_metrics.sql` line 32
|
||||
|
||||
**Description:** `SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END)` excludes products with zero COGS. But if a product has sales revenue and zero COGS (missing cost data), the brand/vendor totals will include the revenue but not the COGS, artificially inflating the margin.
|
||||
|
||||
**Data context:** Brand metrics revenue matches product_metrics aggregation exactly for sales counts, but shows small discrepancies in revenue (e.g., Stamperia: $7,613.98 brand vs $7,611.11 actual). These tiny diffs come from the `> 0` filtering excluding products with negative revenue.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 10: [MEDIUM] Extreme margin values from $0.01 price orders
|
||||
|
||||
**Confirmed by data:** 73 products with margin > 100%, 119 with margin < -100%
|
||||
|
||||
**Examples:**
|
||||
| Product | Revenue | COGS | Margin |
|
||||
|---|---|---|---|
|
||||
| Flower Gift Box Die (pid 624756) | $0.02 | $29.98 | -149,800% |
|
||||
| Special Flowers Stamp Set (pid 614513) | $0.01 | $11.97 | -119,632% |
|
||||
|
||||
These are products with extremely low prices (likely samples, promos, or data errors) where the order price was $0.01. The margin calculation is mathematically correct but these outliers skew any aggregate margin statistics.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 11: [MEDIUM] Sell-through rate has edge cases yielding negative/extreme values
|
||||
|
||||
**File:** `update_product_metrics.sql`, lines 358-361
|
||||
**Confirmed by data:** 30 products with negative sell-through, 10 with sell-through > 200%
|
||||
|
||||
**Description:** Beginning inventory is approximated as `current_stock + sales - received + returns`. When inventory adjustments, shrinkage, or manual corrections occur, this approximation breaks. Edge cases:
|
||||
- Products with many manual stock adjustments → negative denominator → negative sell-through
|
||||
- Products with beginning stock near zero but decent sales → sell-through > 100%
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 12: [MEDIUM] `total_sold` uses different status filter than orders import
|
||||
|
||||
**Import pipeline confirmed:**
|
||||
- Orders import: `order_status >= 15` (includes processing/pending orders)
|
||||
- `total_sold` in products: `order_status >= 20` (more restrictive)
|
||||
|
||||
This means `lifetime_sales` (from `total_sold`) is systematically lower than what you'd calculate by summing the orders table. The discrepancy is confirmed:
|
||||
| Product | total_sold | orders sum | Gap |
|
||||
|---|---|---|---|
|
||||
| pid 31286 | 13,786 | 4,241 | 9,545 |
|
||||
| pid 44309 | 11,978 | 3,119 | 8,859 |
|
||||
|
||||
The large gaps are because the orders table only has data from the import start date (~2024), while `total_sold` includes all-time sales from MySQL. This is expected behavior, not a bug, but it means the `lifetime_revenue_quality` flag is important — most products show 'estimated' quality.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 13: [MEDIUM] Category rollup may double-count products in multiple hierarchy levels
|
||||
|
||||
**File:** `calculate_category_metrics.sql`, lines 42-66
|
||||
|
||||
**Description:** The `RolledUpMetrics` CTE uses:
|
||||
```sql
|
||||
dcm.cat_id = ch.cat_id OR dcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
|
||||
```
|
||||
If products are assigned to categories at multiple levels in the same branch (e.g., both "Paper Crafts" and "Scrapbook Paper" which is a child of "Paper Crafts"), those products' metrics would be counted twice in the parent's rollup.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 14: [LOW] `exclude_forecast` removes products from metrics entirely
|
||||
|
||||
**File:** `update_product_metrics.sql`, line 509
|
||||
|
||||
**Description:** `WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL` is on the main INSERT's WHERE clause. Products with `exclude_forecast = TRUE` won't appear in `product_metrics` at all, rather than just having forecast fields nulled. Currently all 681,912 products are in product_metrics so this appears to not affect any products yet.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 15: [LOW] Daily snapshots only look back 5 days
|
||||
|
||||
**File:** `update_daily_snapshots.sql`, line 14 — `_process_days INT := 5`
|
||||
|
||||
If import data arrives late (>5 days), those days will never get snapshots populated. There is a separate `backfill/rebuild_daily_snapshots.sql` for historical rebuilds.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 16: [INFO] Timezone risk in order date import
|
||||
|
||||
**File:** `inventory-server/scripts/import/orders.js`
|
||||
|
||||
MySQL `DATETIME` values are timezone-naive. The import uses `new Date(order.date)` which interprets them using the import server's local timezone. The SSH config specifies `timezone: '-05:00'` for MySQL (always EST). If the import server is in a different timezone, orders near midnight could land on the wrong date in the daily snapshots calculation.
|
||||
|
||||
---
|
||||
|
||||
## Custom Functions Review
|
||||
|
||||
### `calculate_sales_velocity(sales_30d, stockout_days_30d)`
|
||||
- Divides `sales_30d` by effective selling days: `GREATEST(30 - stockout_days, CASE WHEN sales > 0 THEN 14 ELSE 30 END)`
|
||||
- The 14-day floor prevents extreme velocity for products mostly out of stock
|
||||
- **Sound approach** — the only concern is that stockout_days is unreliable (Issues 2, 3)
|
||||
|
||||
### `get_weighted_avg_cost(pid, date)`
|
||||
- Weighted average of last 10 receivings by cost*qty/qty
|
||||
- Returns NULL if no receivings — sound fallback behavior
|
||||
- **Correct implementation**
|
||||
|
||||
### `safe_divide(numerator, denominator)`
|
||||
- Returns NULL on divide-by-zero — **correct**
|
||||
|
||||
### `std_numeric(value, precision)`
|
||||
- Rounds to precision digits — **correct**
|
||||
|
||||
### `classify_demand_pattern(avg_demand, cv)`
|
||||
- Uses coefficient of variation thresholds: ≤0.2 = stable, ≤0.5 = variable, low-volume+high-CV = sporadic, else lumpy
|
||||
- **Reasonable classification**, though only based on 30-day window
|
||||
|
||||
### `detect_seasonal_pattern(pid)`
|
||||
- CROSS JOIN LATERAL (runs per product) — **expensive**: queries `daily_product_snapshots` twice per product
|
||||
- Compares current month average to yearly average — very simplistic
|
||||
- **Functional but could be a performance bottleneck** with 681K products
|
||||
|
||||
### `category_hierarchy` (materialized view)
|
||||
- Recursive CTE building tree from categories — **correct implementation**
|
||||
- Refreshed concurrently before category metrics calculation — **good practice**
|
||||
|
||||
---
|
||||
|
||||
## Data Health Summary
|
||||
|
||||
| Metric | Count | % of Total |
|
||||
|---|---|---|
|
||||
| Products with zero cost_price | 385,545 | 56.5% |
|
||||
| Products with NULL sales_30d | 621,221 | 91.1% |
|
||||
| Products with no lifetime_sales | 321,321 | 47.1% |
|
||||
| Products with zero COGS but positive sales | 27 | <0.01% |
|
||||
| Products with margin > 100% | 73 | <0.01% |
|
||||
| Products with margin < -100% | 119 | <0.01% |
|
||||
| Products with negative sell-through | 30 | <0.01% |
|
||||
| Products with NULL status | 0 | 0% |
|
||||
| Duplicate daily snapshots (same pid+date) | 0 | 0% |
|
||||
| Net revenue formula mismatches | 0 | 0% |
|
||||
|
||||
### ABC Classification Distribution (replenishable products only)
|
||||
| Class | Products | Revenue % |
|
||||
|---|---|---|
|
||||
| A | 7,727 | 80.72% |
|
||||
| B | 12,048 | 15.10% |
|
||||
| C | 113,647 | 4.18% |
|
||||
|
||||
ABC distribution looks healthy — A ≈ 80%, A+B ≈ 96%.
|
||||
|
||||
### Brand Metrics Consistency
|
||||
Product counts and sales_30d match exactly between `brand_metrics` and direct aggregation from `product_metrics`. Revenue shows sub-dollar discrepancies due to the `> 0` filter excluding products with negative revenue. **Consistent within expected tolerance.**
|
||||
|
||||
---
|
||||
|
||||
## Priority Recommendations
|
||||
|
||||
### Must Fix (Correctness Issues)
|
||||
1. **Issue 1: Fix order status handling** — The text-based filter (`NOT IN ('canceled', 'returned')`) is dead code against numeric statuses. Two options: (a) map numeric statuses to text during import (like POs already do), or (b) change SQL to filter on numeric codes (e.g., `o.status::int >= 20` to exclude cancelled/unfinished, or `o.status IN ('100', '95')` for shipped-only). The ~19.7K unfulfilled orders (0.69%) are a minor financial impact but the filter should be functional.
|
||||
2. **Issue 6: Add supplier_id join to vendor lead time** — One-line fix in `calculate_vendor_metrics.sql`
|
||||
3. **Issue 8: Fix lifetime revenue subquery** — Use correct column names from `daily_product_snapshots` (e.g., `net_revenue / NULLIF(units_sold, 0)`)
|
||||
|
||||
### Should Fix (Data Quality)
|
||||
4. **Issue 2/3: Snapshot coverage** — Consider creating snapshot rows for all in-stock products, not just those with activity. Or at minimum, calculate stockout metrics by comparing snapshot existence to product existence.
|
||||
5. **Issue 5: Populate landing_cost_price** — If available in the source system, import it. Otherwise remove references to avoid confusion.
|
||||
6. **Issue 7: Subtract returns from net_revenue** — `net_revenue = gross_revenue - discounts - returns_revenue`
|
||||
7. **Issue 9: Remove > 0 filter on COGS** — Use `SUM(pm.cogs_30d)` instead of conditional sums
|
||||
|
||||
### Nice to Fix (Edge Cases)
|
||||
8. **Issue 4: Flag estimated costs** — Add a `costeach_estimated BOOLEAN` to orders during import
|
||||
9. **Issue 10: Cap or flag extreme margins** — Exclude $0.01-price orders from margin calculations
|
||||
10. **Issue 11: Clamp sell-through** — `GREATEST(0, LEAST(sell_through_30d, 200))` or flag outliers
|
||||
11. **Issue 12: Verify category assignment policy** — Check if products are assigned to leaf categories only
|
||||
12. **Issue 13: Category rollup query** — Verify no double-counting with actual data
|
||||
276
docs/METRICS_AUDIT2.md
Normal file
276
docs/METRICS_AUDIT2.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Metrics Pipeline Audit Report
|
||||
|
||||
**Date:** 2026-02-08
|
||||
**Scope:** All 6 SQL scripts in `inventory-server/scripts/metrics-new/`, import pipeline, custom functions, and post-calculation data verification.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The metrics pipeline is architecturally sound and the core calculations are mostly correct. The 30-day sales, revenue, replenishment, and aggregate metrics (brand/vendor/category) all cross-check accurately between the snapshots, product_metrics, and direct orders queries. However, several issues were found ranging from **critical data bugs** to **design limitations** that affect accuracy of specific metrics.
|
||||
|
||||
**Issues found: 13** (3 Critical, 4 Medium, 6 Low/Informational)
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL Issues
|
||||
|
||||
### C1. `net_revenue` in daily snapshots never subtracts returns ($35.6K affected)
|
||||
|
||||
**Location:** `update_daily_snapshots.sql`, line 181
|
||||
**Symptom:** `net_revenue` is stored as `gross_revenue - discounts` but should be `gross_revenue - discounts - returns_revenue`.
|
||||
|
||||
The SQL formula on line 181 appears correct:
|
||||
```sql
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue
|
||||
```
|
||||
|
||||
However, actual data shows `net_revenue = gross_revenue - discounts` for ALL 3,252 snapshots that have returns. Total returns not subtracted: **$35,630.03** across 2,946 products. This may be caused by the `returns_revenue` in the SalesData CTE not properly flowing through to the INSERT, or by a prior version of the code that stored these values differently. The profit column (line 184) has the same issue: `(gross - discounts) - cogs` instead of `(gross - discounts - returns) - cogs`.
|
||||
|
||||
**Impact:** Net revenue and profit are overstated by the amount of returns. This cascades to all metrics derived from snapshots: `revenue_30d`, `profit_30d`, `margin_30d`, `avg_ros_30d`, and all brand/vendor/category aggregate revenue.
|
||||
|
||||
**Recommended fix:** Debug why the returns subtraction isn't taking effect. The formula in the SQL looks correct, so this may be a data-type issue or an execution path issue. After fixing, rebuild snapshots.
|
||||
|
||||
**Status:** Owner will resolve. Code formula is correct; snapshots need rebuilding after prior fix deployment.
|
||||
|
||||
---
|
||||
|
||||
### C2. `eod_stock_quantity` uses CURRENT stock, not historical end-of-day stock
|
||||
|
||||
**Location:** `update_daily_snapshots.sql`, lines 123-132 (CurrentStock CTE)
|
||||
**Symptom:** Every snapshot for a given product shows the same stock quantity regardless of the snapshot date.
|
||||
|
||||
The `CurrentStock` CTE simply reads `stock_quantity` from the `products` table:
|
||||
```sql
|
||||
SELECT pid, stock_quantity, ... FROM public.products
|
||||
```
|
||||
|
||||
This means a snapshot from January 10 shows the SAME stock as today (February 8). Verified in data:
|
||||
- Product 662561: stock = 36 on every date (Feb 1-7)
|
||||
- Product 665397: stock = 25 on every date (Feb 1-7)
|
||||
- All products checked show identical stock across all snapshot dates
|
||||
|
||||
**Impact:** All stock-derived metrics are inaccurate for historical analysis:
|
||||
- `eod_stock_cost`, `eod_stock_retail`, `eod_stock_gross` (all wrong for past dates)
|
||||
- `stockout_flag` (based on current stock, not historical)
|
||||
- `stockout_days_30d` (undercounted since stockout_flag uses current stock)
|
||||
- `avg_stock_units_30d`, `avg_stock_cost_30d` (no variance, just current stock repeated)
|
||||
- `gmroi_30d`, `stockturn_30d` (based on avg_stock which is flat)
|
||||
- `sell_through_30d` (denominator uses current stock assumption)
|
||||
- `service_level_30d`, `fill_rate_30d`
|
||||
|
||||
**This is a known architectural limitation** noted in MEMORY.md. Fixing requires either:
|
||||
1. Storing stock snapshots separately at end-of-day (ideally via a cron job that records stock before any changes)
|
||||
2. Reconstructing historical stock from orders and receivings (complex but possible)
|
||||
|
||||
**Status: FIXED.** MySQL's `snap_product_value` table (daily EOD stock per product since 2012) is now imported into PostgreSQL `stock_snapshots` table via `scripts/import/stock-snapshots.js`. The `CurrentStock` CTE in `update_daily_snapshots.sql` now uses `LEFT JOIN stock_snapshots` for historical stock, falling back to `products.stock_quantity` when no historical data exists. Requires: run import, then rebuild daily snapshots.
|
||||
|
||||
---
|
||||
|
||||
### C3. `ON CONFLICT DO UPDATE WHERE` check skips 91%+ of product_metrics updates
|
||||
|
||||
**Location:** `update_product_metrics.sql`, lines 558-574
|
||||
**Symptom:** 623,205 of 681,912 products (91.4%) have `last_calculated` older than 1 day. 592,369 are over 30 days old. 914 products with active 30-day sales haven't been updated in over 7 days.
|
||||
|
||||
The upsert's `WHERE` clause only updates if specific fields changed:
|
||||
```sql
|
||||
WHERE product_metrics.current_stock IS DISTINCT FROM EXCLUDED.current_stock OR
|
||||
product_metrics.current_price IS DISTINCT FROM EXCLUDED.current_price OR ...
|
||||
```
|
||||
|
||||
Fields NOT checked include: `stockout_days_30d`, `margin_30d`, `gmroi_30d`, `demand_pattern`, `seasonality_index`, `sales_growth_*`, `service_level_30d`, and many others. If a product's stock, price, sales, and revenue haven't changed, the entire row is skipped even though growth metrics, variability, and other derived fields may need updating.
|
||||
|
||||
**Impact:** Most derived metrics (growth, demand patterns, seasonality) are stale for the majority of products. Products with steady sales but unchanged stock/price never get their growth metrics recalculated.
|
||||
|
||||
**Recommended fix:** Either:
|
||||
1. Remove the `WHERE` clause entirely (accept the performance cost of writing all rows every run)
|
||||
2. Add `last_calculated` age check: `OR product_metrics.last_calculated < NOW() - INTERVAL '7 days'`
|
||||
3. Add the missing fields to the change-detection check
|
||||
|
||||
**Status: FIXED.** Added 12 derived fields to the `IS DISTINCT FROM` check (`profit_30d`, `cogs_30d`, `margin_30d`, `stockout_days_30d`, `sell_through_30d`, `sales_growth_30d_vs_prev`, `revenue_growth_30d_vs_prev`, `demand_pattern`, `seasonal_pattern`, `seasonality_index`, `service_level_30d`, `fill_rate_30d`) plus a time-based safety net: `OR product_metrics.last_calculated < NOW() - INTERVAL '1 day'`. This guarantees every row is refreshed at least daily.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM Issues
|
||||
|
||||
### M1. Demand variability calculated only over activity days, not full 30-day window
|
||||
|
||||
**Location:** `update_product_metrics.sql`, DemandVariability CTE (lines 206-223)
|
||||
**Symptom:** Variance, std_dev, and CV are computed over only the days that appear in snapshots (activity days), not the full 30-day period including zero-sales days.
|
||||
|
||||
Example: Product 41141 (Mexican Poppy) sold 102 units in 30 days across only 3 snapshot days (1, 1, 100). The variance/CV is calculated over just those 3 data points instead of 30 (with 27 zero-sales days).
|
||||
|
||||
**Impact:**
|
||||
- CV is computed on sparse data (3-10 points instead of 30), making it statistically unreliable
|
||||
- Products with sporadic large orders appear less variable than they really are
|
||||
- `demand_pattern` classification is affected (stable/variable/sporadic/lumpy)
|
||||
|
||||
**Recommended fix:** Join against a generated 30-day date series and COALESCE missing days to 0 units sold before computing variance/stddev/CV.
|
||||
|
||||
**Status: FIXED.** Rewrote `DemandVariability` CTE to use `generate_series()` for the full 30-day date range, `CROSS JOIN` with distinct PIDs from snapshots, and `LEFT JOIN` actual snapshot data with `COALESCE(dps.units_sold, 0)` for missing days. Variance/stddev/CV now computed over all 30 data points.
|
||||
|
||||
---
|
||||
|
||||
### M2. `costeach` fallback to `price * 0.5` affects 32.5% of recent orders
|
||||
|
||||
**Location:** `orders.js`, line 600 and 634
|
||||
**Symptom:** When no cost record exists in `order_costs`, the import falls back to `price * 0.5`.
|
||||
|
||||
Data shows 9,839 of 30,266 recent orders (32.5%) use this fallback. Among these, 79 paid products have `costeach = 0` because `price = 0 * 0.5 = 0`, even though the product has a real cost_price.
|
||||
|
||||
The daily snapshot has a second line of defense (using `get_weighted_avg_cost()` and then `p.cost_price`), but the orders table's `costeach` column itself contains inaccurate data for ~1/3 of orders.
|
||||
|
||||
**Impact:** COGS calculations at the order level are approximate for 1/3 of orders. The snapshot's fallback chain mitigates this somewhat, but any analytics using `orders.costeach` directly will be affected.
|
||||
|
||||
**Status: FIXED.** Added `products.cost_price` as intermediate fallback: `COALESCE(oc.costeach, p.cost_price, oi.price * 0.5)`. The products table join was added to both the `order_totals` CTE and the outer SELECT in `orders.js`. Requires a full orders re-import to apply retroactively.
|
||||
|
||||
---
|
||||
|
||||
### M3. `lifetime_sales` uses MySQL `total_sold` (status >= 20) but orders import uses status >= 15
|
||||
|
||||
**Location:** `products.js` line 200 vs `orders.js` line 69
|
||||
**Symptom:** `total_sold` in the products table comes from MySQL with `order_status >= 20`, excluding status 15 (canceled) and 16 (combined). But the orders import fetches orders with `order_status >= 15`.
|
||||
|
||||
Verified in MySQL: For product 31286, `total_sold` (>=20) = 13,786 vs (>=15) = 13,905 (difference of 119 units).
|
||||
|
||||
**Impact:** `lifetime_sales` in product_metrics (sourced from `products.total_sold`) slightly understates compared to what the orders table contains. The `lifetime_revenue_quality` field correctly flags most as "estimated" since the orders table only covers ~5 years while `total_sold` is all-time. This is a minor inconsistency (< 1% difference).
|
||||
|
||||
**Status:** Accepted. < 1% difference, not worth the complexity of aligning thresholds.
|
||||
|
||||
---
|
||||
|
||||
### M4. `sell_through_30d` has 868 NULL values and 547 anomalous values for products with sales
|
||||
|
||||
**Location:** `update_product_metrics.sql`, lines 356-361
|
||||
**Formula:** `(sales_30d / (current_stock + sales_30d + returns_units_30d - received_qty_30d)) * 100`
|
||||
|
||||
- 868 products with sales but NULL sell_through (denominator = 0, which happens when `current_stock + sales - received = 0`, i.e. all stock came from receiving and was sold)
|
||||
- 259 products with sell_through > 100%
|
||||
- 288 products with negative sell_through
|
||||
|
||||
**Impact:** Sell-through rate is unreliable for products with significant receiving activity in the same period. The formula tries to approximate "beginning inventory" but the approximation breaks when current stock ≠ actual beginning stock (which is always, per issue C2).
|
||||
|
||||
**Status:** Will improve once C2 fix (historical stock) is deployed and snapshots are rebuilt, since `current_stock` in the formula will then reflect actual beginning inventory.
|
||||
|
||||
---
|
||||
|
||||
## LOW / INFORMATIONAL Issues
|
||||
|
||||
### L1. Snapshots only cover ~1,167 products/day out of 681K
|
||||
|
||||
Only products with order or receiving activity on a given day get snapshots. This is by design (the `ProductsWithActivity` CTE on line 133 of `update_daily_snapshots.sql`), but it means:
|
||||
- 560K+ products have zero snapshot history
|
||||
- Stockout tracking is impossible for products with no sales (they can't appear in snapshots)
|
||||
- The "avg_stock" metrics (avg_stock_units_30d, etc.) only average over activity days, not all 30 days
|
||||
|
||||
This is acceptable for storage efficiency but should be understood when interpreting metrics.
|
||||
|
||||
**Status:** Accepted (by design).
|
||||
|
||||
---
|
||||
|
||||
### L2. `detect_seasonal_pattern` function only compares current month to yearly average
|
||||
|
||||
The seasonality detection is simplistic: it compares current month's avg daily sales to yearly avg. This means:
|
||||
- It can only detect if the CURRENT month is above average, not identify historical seasonal patterns
|
||||
- Running in January vs July will give completely different results for the same product
|
||||
- The "peak_season" field always shows the current month/quarter when seasonal (not the actual peak)
|
||||
|
||||
This is noted as a P5 (low priority) feature and is adequate for a first pass but should not be relied upon for demand planning.
|
||||
|
||||
**Status: FIXED.** Rewrote `detect_seasonal_pattern` function to compare monthly average sales across the full last 12 months. Uses CV across months + peak-to-average ratio for classification: `strong` (CV > 0.5, peak > 150%), `moderate` (CV > 0.3, peak > 120%), `none`. Peak season now identifies the actual highest-sales month. Requires at least 3 months of data. Saved in `db/functions.sql`.
|
||||
|
||||
---
|
||||
|
||||
### L3. Free product with negative revenue in top sellers
|
||||
|
||||
Product 476848 ("Thank You, From ACOT!") shows 254 sales with -$1.00 revenue because one order applied a $1 discount to a $0 product. This is a data oddity, not a calculation bug. Could be addressed by excluding $0-price products from revenue metrics or by data cleanup.
|
||||
|
||||
**Status:** Accepted (data oddity, not a bug).
|
||||
|
||||
---
|
||||
|
||||
### L4. `landing_cost_price` is always NULL
|
||||
|
||||
`current_landing_cost_price` in product_metrics is mapped from `current_effective_cost` which is just `cost_price`. The `landing_cost_price` concept (cost + shipping + duties) is not implemented. The field exists but has no meaningful data.
|
||||
|
||||
**Status: FIXED.** Removed `landing_cost_price` from `db/schema.sql`, `current_landing_cost_price` from `db/metrics-schema-new.sql`, `update_product_metrics.sql`, and `backfill/populate_initial_product_metrics.sql`. Column should be dropped from the live database via `ALTER TABLE`.
|
||||
|
||||
---
|
||||
|
||||
### L5. Custom SQL functions not tracked in version control
|
||||
|
||||
All 6 custom functions (`calculate_sales_velocity`, `get_weighted_avg_cost`, `safe_divide`, `std_numeric`, `classify_demand_pattern`, `detect_seasonal_pattern`) and the `category_hierarchy` materialized view exist only in the database. They are not defined in any migration or schema file in the repository.
|
||||
|
||||
If the database needs to be recreated, these would be lost.
|
||||
|
||||
**Status: FIXED.** All 6 functions and the `category_hierarchy` materialized view definition saved to `inventory-server/db/functions.sql`. File is re-runnable via `psql -f functions.sql`.
|
||||
|
||||
---
|
||||
|
||||
### L6. `get_weighted_avg_cost` limited to last 10 receivings
|
||||
|
||||
The function `LIMIT 10` for performance, but this means products with many small receivings may not accurately reflect the true weighted average cost if the cost has changed significantly beyond the last 10 receiving records.
|
||||
|
||||
**Status: FIXED.** Removed `LIMIT 10` from `get_weighted_avg_cost`. Data shows max receivings per product is 142 (p95 = 11, avg = 3), so performance impact is negligible. Updated definition in `db/functions.sql`.
|
||||
|
||||
---
|
||||
|
||||
## Verification Summary
|
||||
|
||||
### What's Working Correctly
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| 30d sales: product_metrics vs orders vs snapshots | **MATCH** (verified top 10 sellers) |
|
||||
| Replenishment formula: manual calc vs stored | **MATCH** (verified 10 products) |
|
||||
| Brand metrics vs sum of product_metrics | **MATCH** (0 difference across all brands) |
|
||||
| Order status mapping (numeric → text) | **CORRECT** (all statuses mapped, no numeric remain) |
|
||||
| Cost price: PostgreSQL vs MySQL source | **MATCH** (within rounding, verified 5 products) |
|
||||
| total_sold: PostgreSQL vs MySQL source | **MATCH** (verified 5 products) |
|
||||
| Category rollups (rolled-up > direct for parents) | **CORRECT** |
|
||||
| ABC classification distribution | **REASONABLE** (A: 8K, B: 12.5K, C: 113K) |
|
||||
| Lead time calculation (PO → receiving) | **CORRECT** (verified examples) |
|
||||
|
||||
### Data Overview
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total products | 681,912 |
|
||||
| Products in product_metrics | 681,912 (100%) |
|
||||
| Products with 30d sales | 10,291 (1.5%) |
|
||||
| Products with negative profit & revenue | 139 (mostly cost > price) |
|
||||
| Products with negative stock | 0 |
|
||||
| Snapshot date range | 2020-06-18 to 2026-02-08 |
|
||||
| Avg products per snapshot day | 1,167 |
|
||||
| Order date range | 2020-06-18 to 2026-02-08 |
|
||||
| Total orders | 2,885,825 |
|
||||
| 'returned' status orders | 0 (returns via negative quantity only) |
|
||||
|
||||
---
|
||||
|
||||
## Fix Status Summary
|
||||
|
||||
| Issue | Severity | Status | Deployment Action Needed |
|
||||
|-------|----------|--------|--------------------------|
|
||||
| C1 | Critical | Owner resolving | Rebuild daily snapshots |
|
||||
| C2 | Critical | **FIXED** | Run import, rebuild daily snapshots |
|
||||
| C3 | Critical | **FIXED** | Deploy updated `update_product_metrics.sql` |
|
||||
| M1 | Medium | **FIXED** | Deploy updated `update_product_metrics.sql` |
|
||||
| M2 | Medium | **FIXED** | Full orders re-import (`--full`) |
|
||||
| M3 | Medium | Accepted | None |
|
||||
| M4 | Medium | Pending C2 | Will improve after C2 deployment |
|
||||
| L1 | Low | Accepted | None |
|
||||
| L2 | Low | **FIXED** | Deploy `db/functions.sql` to database |
|
||||
| L3 | Low | Accepted | None |
|
||||
| L4 | Low | **FIXED** | `ALTER TABLE` to drop columns |
|
||||
| L5 | Low | **FIXED** | None (file committed) |
|
||||
| L6 | Low | **FIXED** | Deploy `db/functions.sql` to database |
|
||||
|
||||
### Deployment Steps
|
||||
|
||||
1. Deploy `db/functions.sql` to PostgreSQL: `psql -d inventory_db -f db/functions.sql` (L2, L6)
|
||||
2. Run import (includes stock snapshots first load) (C2, M2)
|
||||
3. Drop stale columns: `ALTER TABLE products DROP COLUMN IF EXISTS landing_cost_price; ALTER TABLE product_metrics DROP COLUMN IF EXISTS current_landing_cost_price;` (L4)
|
||||
4. Rebuild daily snapshots (C1, C2)
|
||||
5. Re-run metrics calculation (C3, M1 take effect automatically)
|
||||
234
inventory-server/db/functions.sql
Normal file
234
inventory-server/db/functions.sql
Normal file
@@ -0,0 +1,234 @@
|
||||
-- Custom PostgreSQL functions used by the metrics pipeline
|
||||
-- These must exist in the database before running calculate-metrics-new.js
|
||||
--
|
||||
-- To install/update: psql -d inventory_db -f functions.sql
|
||||
-- All functions use CREATE OR REPLACE so they are safe to re-run.
|
||||
|
||||
-- =============================================================================
|
||||
-- safe_divide: Division helper that returns a default value instead of erroring
|
||||
-- on NULL or zero denominators.
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.safe_divide(
|
||||
numerator numeric,
|
||||
denominator numeric,
|
||||
default_value numeric DEFAULT NULL::numeric
|
||||
)
|
||||
RETURNS numeric
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF denominator IS NULL OR denominator = 0 THEN
|
||||
RETURN default_value;
|
||||
ELSE
|
||||
RETURN numerator / denominator;
|
||||
END IF;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- std_numeric: Standardized rounding helper for consistent numeric precision.
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.std_numeric(
|
||||
value numeric,
|
||||
precision_digits integer DEFAULT 2
|
||||
)
|
||||
RETURNS numeric
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF value IS NULL THEN
|
||||
RETURN NULL;
|
||||
ELSE
|
||||
RETURN ROUND(value, precision_digits);
|
||||
END IF;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- calculate_sales_velocity: Daily sales velocity adjusted for stockout days.
|
||||
-- Ensures at least 14-day denominator for products with sales to avoid
|
||||
-- inflated velocity from short windows.
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.calculate_sales_velocity(
|
||||
sales_30d integer,
|
||||
stockout_days_30d integer
|
||||
)
|
||||
RETURNS numeric
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $function$
|
||||
BEGIN
|
||||
RETURN sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - stockout_days_30d,
|
||||
CASE
|
||||
WHEN sales_30d > 0 THEN 14.0 -- If we have sales, ensure at least 14 days denominator
|
||||
ELSE 30.0 -- If no sales, use full period
|
||||
END
|
||||
),
|
||||
0
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- get_weighted_avg_cost: Weighted average cost from receivings up to a given date.
|
||||
-- Uses all non-canceled receivings (no row limit) weighted by quantity.
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.get_weighted_avg_cost(
|
||||
p_pid bigint,
|
||||
p_date date
|
||||
)
|
||||
RETURNS numeric
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
AS $function$
|
||||
DECLARE
|
||||
weighted_cost NUMERIC;
|
||||
BEGIN
|
||||
SELECT
|
||||
CASE
|
||||
WHEN SUM(qty_each) > 0 THEN SUM(cost_each * qty_each) / SUM(qty_each)
|
||||
ELSE NULL
|
||||
END INTO weighted_cost
|
||||
FROM receivings
|
||||
WHERE pid = p_pid
|
||||
AND received_date <= p_date
|
||||
AND status != 'canceled';
|
||||
|
||||
RETURN weighted_cost;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- classify_demand_pattern: Classifies demand based on average demand and
|
||||
-- coefficient of variation (CV). Standard inventory classification:
|
||||
-- zero: no demand
|
||||
-- stable: CV <= 0.2 (predictable, easy to forecast)
|
||||
-- variable: CV <= 0.5 (some variability, still forecastable)
|
||||
-- sporadic: low volume + high CV (intermittent demand)
|
||||
-- lumpy: high volume + high CV (unpredictable bursts)
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.classify_demand_pattern(
|
||||
avg_demand numeric,
|
||||
cv numeric
|
||||
)
|
||||
RETURNS character varying
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF avg_demand IS NULL OR cv IS NULL THEN
|
||||
RETURN NULL;
|
||||
ELSIF avg_demand = 0 THEN
|
||||
RETURN 'zero';
|
||||
ELSIF cv <= 0.2 THEN
|
||||
RETURN 'stable';
|
||||
ELSIF cv <= 0.5 THEN
|
||||
RETURN 'variable';
|
||||
ELSIF avg_demand < 1.0 THEN
|
||||
RETURN 'sporadic';
|
||||
ELSE
|
||||
RETURN 'lumpy';
|
||||
END IF;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- detect_seasonal_pattern: Detects seasonality by comparing monthly average
|
||||
-- sales across the last 12 months. Uses coefficient of variation across months
|
||||
-- and peak-to-average ratio to classify patterns.
|
||||
--
|
||||
-- Returns:
|
||||
-- seasonal_pattern: 'none', 'moderate', or 'strong'
|
||||
-- seasonality_index: peak month avg / overall avg * 100 (100 = no seasonality)
|
||||
-- peak_season: name of peak month (e.g. 'January'), or NULL if none
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.detect_seasonal_pattern(p_pid bigint)
|
||||
RETURNS TABLE(seasonal_pattern character varying, seasonality_index numeric, peak_season character varying)
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_monthly_cv NUMERIC;
|
||||
v_max_month_avg NUMERIC;
|
||||
v_overall_avg NUMERIC;
|
||||
v_monthly_stddev NUMERIC;
|
||||
v_peak_month_num INT;
|
||||
v_data_months INT;
|
||||
v_seasonality_index NUMERIC;
|
||||
v_seasonal_pattern VARCHAR;
|
||||
v_peak_season VARCHAR;
|
||||
BEGIN
|
||||
-- Gather monthly average sales and peak month in a single query
|
||||
SELECT
|
||||
COUNT(*),
|
||||
AVG(month_avg),
|
||||
STDDEV(month_avg),
|
||||
MAX(month_avg),
|
||||
(ARRAY_AGG(mo ORDER BY month_avg DESC))[1]::INT
|
||||
INTO v_data_months, v_overall_avg, v_monthly_stddev, v_max_month_avg, v_peak_month_num
|
||||
FROM (
|
||||
SELECT EXTRACT(MONTH FROM snapshot_date) AS mo, AVG(units_sold) AS month_avg
|
||||
FROM daily_product_snapshots
|
||||
WHERE pid = p_pid AND snapshot_date >= CURRENT_DATE - INTERVAL '365 days'
|
||||
GROUP BY EXTRACT(MONTH FROM snapshot_date)
|
||||
) monthly;
|
||||
|
||||
-- Need at least 3 months of data for meaningful seasonality detection
|
||||
IF v_data_months < 3 OR v_overall_avg IS NULL OR v_overall_avg = 0 THEN
|
||||
RETURN QUERY SELECT 'none'::VARCHAR, 100::NUMERIC, NULL::VARCHAR;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- CV of monthly averages
|
||||
v_monthly_cv := v_monthly_stddev / v_overall_avg;
|
||||
|
||||
-- Seasonality index: peak month avg / overall avg * 100
|
||||
v_seasonality_index := ROUND((v_max_month_avg / v_overall_avg * 100)::NUMERIC, 2);
|
||||
|
||||
IF v_monthly_cv > 0.5 AND v_seasonality_index > 150 THEN
|
||||
v_seasonal_pattern := 'strong';
|
||||
v_peak_season := TRIM(TO_CHAR(TO_DATE(v_peak_month_num::TEXT, 'MM'), 'Month'));
|
||||
ELSIF v_monthly_cv > 0.3 AND v_seasonality_index > 120 THEN
|
||||
v_seasonal_pattern := 'moderate';
|
||||
v_peak_season := TRIM(TO_CHAR(TO_DATE(v_peak_month_num::TEXT, 'MM'), 'Month'));
|
||||
ELSE
|
||||
v_seasonal_pattern := 'none';
|
||||
v_peak_season := NULL;
|
||||
v_seasonality_index := 100;
|
||||
END IF;
|
||||
|
||||
RETURN QUERY SELECT v_seasonal_pattern, v_seasonality_index, v_peak_season;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- category_hierarchy: Materialized view providing a recursive category tree
|
||||
-- with ancestor paths for efficient rollup queries.
|
||||
--
|
||||
-- Refresh after category changes: REFRESH MATERIALIZED VIEW category_hierarchy;
|
||||
-- =============================================================================
|
||||
-- DROP MATERIALIZED VIEW IF EXISTS category_hierarchy;
|
||||
-- CREATE MATERIALIZED VIEW category_hierarchy AS
|
||||
-- WITH RECURSIVE cat_tree AS (
|
||||
-- SELECT cat_id, name, type, parent_id,
|
||||
-- cat_id AS root_id, 0 AS level, ARRAY[cat_id] AS path
|
||||
-- FROM categories
|
||||
-- WHERE parent_id IS NULL
|
||||
-- UNION ALL
|
||||
-- SELECT c.cat_id, c.name, c.type, c.parent_id,
|
||||
-- ct.root_id, ct.level + 1, ct.path || c.cat_id
|
||||
-- FROM categories c
|
||||
-- JOIN cat_tree ct ON c.parent_id = ct.cat_id
|
||||
-- )
|
||||
-- SELECT cat_id, name, type, parent_id, root_id, level, path,
|
||||
-- (SELECT array_agg(unnest ORDER BY unnest DESC)
|
||||
-- FROM unnest(cat_tree.path) unnest
|
||||
-- WHERE unnest <> cat_tree.cat_id) AS ancestor_ids
|
||||
-- FROM cat_tree;
|
||||
--
|
||||
-- CREATE UNIQUE INDEX ON category_hierarchy (cat_id);
|
||||
@@ -80,7 +80,6 @@ CREATE TABLE public.product_metrics (
|
||||
current_price NUMERIC(10, 2),
|
||||
current_regular_price NUMERIC(10, 2),
|
||||
current_cost_price NUMERIC(10, 4), -- Increased precision for cost
|
||||
current_landing_cost_price NUMERIC(10, 4), -- Increased precision for cost
|
||||
current_stock INT NOT NULL DEFAULT 0,
|
||||
current_stock_cost NUMERIC(14, 4) NOT NULL DEFAULT 0.00,
|
||||
current_stock_retail NUMERIC(14, 4) NOT NULL DEFAULT 0.00,
|
||||
@@ -156,9 +155,9 @@ CREATE TABLE public.product_metrics (
|
||||
days_of_stock_closing_stock NUMERIC(10, 2), -- lead_time_closing_stock - days_of_stock_forecast_units
|
||||
replenishment_needed_raw NUMERIC(10, 2), -- planning_period_forecast_units + config_safety_stock - current_stock - on_order_qty
|
||||
replenishment_units INT, -- CEILING(GREATEST(0, replenishment_needed_raw))
|
||||
replenishment_cost NUMERIC(14, 4), -- replenishment_units * COALESCE(current_landing_cost_price, current_cost_price)
|
||||
replenishment_cost NUMERIC(14, 4), -- replenishment_units * current_cost_price
|
||||
replenishment_retail NUMERIC(14, 4), -- replenishment_units * current_price
|
||||
replenishment_profit NUMERIC(14, 4), -- replenishment_units * (current_price - COALESCE(current_landing_cost_price, current_cost_price))
|
||||
replenishment_profit NUMERIC(14, 4), -- replenishment_units * (current_price - current_cost_price)
|
||||
to_order_units INT, -- Apply MOQ/UOM logic to replenishment_units
|
||||
forecast_lost_sales_units NUMERIC(10, 2), -- GREATEST(0, -lead_time_closing_stock)
|
||||
forecast_lost_revenue NUMERIC(14, 4), -- forecast_lost_sales_units * current_price
|
||||
@@ -167,7 +166,7 @@ CREATE TABLE public.product_metrics (
|
||||
sells_out_in_days NUMERIC(10, 1), -- (current_stock + on_order_qty) / sales_velocity_daily
|
||||
replenish_date DATE, -- Calc based on when stock hits safety stock minus lead time
|
||||
overstocked_units INT, -- GREATEST(0, current_stock - config_safety_stock - planning_period_forecast_units)
|
||||
overstocked_cost NUMERIC(14, 4), -- overstocked_units * COALESCE(current_landing_cost_price, current_cost_price)
|
||||
overstocked_cost NUMERIC(14, 4), -- overstocked_units * current_cost_price
|
||||
overstocked_retail NUMERIC(14, 4), -- overstocked_units * current_price
|
||||
is_old_stock BOOLEAN, -- Based on age, last sold, last received, on_order status
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ CREATE TABLE products (
|
||||
price NUMERIC(14, 4) NOT NULL,
|
||||
regular_price NUMERIC(14, 4) NOT NULL,
|
||||
cost_price NUMERIC(14, 4),
|
||||
landing_cost_price NUMERIC(14, 4),
|
||||
barcode TEXT,
|
||||
harmonized_tariff_code TEXT,
|
||||
updated_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
@@ -11,6 +11,7 @@ const RUN_PERIODIC_METRICS = true;
|
||||
const RUN_BRAND_METRICS = true;
|
||||
const RUN_VENDOR_METRICS = true;
|
||||
const RUN_CATEGORY_METRICS = true;
|
||||
const RUN_LIFECYCLE_FORECASTS = true;
|
||||
|
||||
// Maximum execution time for the entire sequence (e.g., 90 minutes)
|
||||
const MAX_EXECUTION_TIME_TOTAL = 90 * 60 * 1000;
|
||||
@@ -592,6 +593,13 @@ async function runAllCalculations() {
|
||||
historyType: 'product_metrics',
|
||||
statusModule: 'product_metrics'
|
||||
},
|
||||
{
|
||||
run: RUN_LIFECYCLE_FORECASTS,
|
||||
name: 'Lifecycle Forecast Update',
|
||||
sqlFile: 'metrics-new/update_lifecycle_forecasts.sql',
|
||||
historyType: 'lifecycle_forecasts',
|
||||
statusModule: 'lifecycle_forecasts'
|
||||
},
|
||||
{
|
||||
run: RUN_PERIODIC_METRICS,
|
||||
name: 'Periodic Metrics Update',
|
||||
|
||||
Binary file not shown.
1619
inventory-server/scripts/forecast/forecast_engine.py
Normal file
1619
inventory-server/scripts/forecast/forecast_engine.py
Normal file
File diff suppressed because it is too large
Load Diff
5
inventory-server/scripts/forecast/requirements.txt
Normal file
5
inventory-server/scripts/forecast/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
numpy>=1.24
|
||||
scipy>=1.10
|
||||
pandas>=2.0
|
||||
psycopg2-binary>=2.9
|
||||
statsmodels>=0.14
|
||||
128
inventory-server/scripts/forecast/run_forecast.js
Normal file
128
inventory-server/scripts/forecast/run_forecast.js
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Forecast Pipeline Orchestrator
|
||||
*
|
||||
* Spawns the Python forecast engine with database credentials from the
|
||||
* environment. Can be run manually, via cron, or integrated into the
|
||||
* existing metrics pipeline.
|
||||
*
|
||||
* Usage:
|
||||
* node run_forecast.js
|
||||
*
|
||||
* Environment:
|
||||
* Reads DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT from
|
||||
* /var/www/html/inventory/.env (or current process env).
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Load .env file if it exists (production path)
|
||||
const envPaths = [
|
||||
'/var/www/html/inventory/.env',
|
||||
path.join(__dirname, '../../.env'),
|
||||
];
|
||||
|
||||
for (const envPath of envPaths) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, 'utf-8');
|
||||
for (const line of envContent.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
const key = trimmed.slice(0, eqIndex);
|
||||
const value = trimmed.slice(eqIndex + 1);
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
console.log(`Loaded env from ${envPath}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify required env vars
|
||||
const required = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
|
||||
const missing = required.filter(k => !process.env[k]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SCRIPT_DIR = __dirname;
|
||||
const PYTHON_SCRIPT = path.join(SCRIPT_DIR, 'forecast_engine.py');
|
||||
const VENV_DIR = path.join(SCRIPT_DIR, 'venv');
|
||||
const REQUIREMENTS = path.join(SCRIPT_DIR, 'requirements.txt');
|
||||
|
||||
// Determine python binary (prefer venv if it exists)
|
||||
function getPythonBin() {
|
||||
const venvPython = path.join(VENV_DIR, 'bin', 'python');
|
||||
if (fs.existsSync(venvPython)) return venvPython;
|
||||
|
||||
// Fall back to system python
|
||||
return 'python3';
|
||||
}
|
||||
|
||||
// Ensure venv and dependencies are installed
|
||||
async function ensureDependencies() {
|
||||
if (!fs.existsSync(path.join(VENV_DIR, 'bin', 'python'))) {
|
||||
console.log('Creating virtual environment...');
|
||||
await runCommand('python3', ['-m', 'venv', VENV_DIR]);
|
||||
}
|
||||
|
||||
// Always run pip install — idempotent, fast when packages already present
|
||||
console.log('Checking dependencies...');
|
||||
const python = path.join(VENV_DIR, 'bin', 'python');
|
||||
await runCommand(python, ['-m', 'pip', 'install', '--quiet', '-r', REQUIREMENTS]);
|
||||
}
|
||||
|
||||
function runCommand(cmd, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
...options,
|
||||
});
|
||||
proc.on('close', code => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${cmd} exited with code ${code}`));
|
||||
});
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const startTime = Date.now();
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Forecast Pipeline - ${new Date().toISOString()}`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
await ensureDependencies();
|
||||
|
||||
const pythonBin = getPythonBin();
|
||||
console.log(`Using Python: ${pythonBin}`);
|
||||
console.log(`Running: ${PYTHON_SCRIPT}`);
|
||||
console.log('');
|
||||
|
||||
await runCommand(pythonBin, [PYTHON_SCRIPT], {
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1', // Real-time output
|
||||
},
|
||||
});
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log('');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Forecast pipeline completed in ${duration}s`);
|
||||
console.log('='.repeat(60));
|
||||
} catch (err) {
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.error(`Forecast pipeline FAILED after ${duration}s:`, err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
51
inventory-server/scripts/forecast/sql/create_tables.sql
Normal file
51
inventory-server/scripts/forecast/sql/create_tables.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Forecasting Pipeline Tables
|
||||
-- Run once to create the schema. Safe to re-run (IF NOT EXISTS).
|
||||
|
||||
-- Precomputed reference decay curves per brand (or brand x category at any hierarchy level)
|
||||
CREATE TABLE IF NOT EXISTS brand_lifecycle_curves (
|
||||
id SERIAL PRIMARY KEY,
|
||||
brand TEXT NOT NULL,
|
||||
root_category TEXT, -- NULL = brand-level fallback curve, else category name
|
||||
cat_id BIGINT, -- NULL = brand-only; else category_hierarchy.cat_id for precise matching
|
||||
category_level SMALLINT, -- NULL = brand-only; 0-3 = hierarchy depth
|
||||
amplitude NUMERIC(10,4), -- A in: sales(t) = A * exp(-λt) + C
|
||||
decay_rate NUMERIC(10,6), -- λ (higher = faster decay)
|
||||
baseline NUMERIC(10,4), -- C (long-tail steady-state daily sales)
|
||||
r_squared NUMERIC(6,4), -- goodness of fit
|
||||
sample_size INT, -- number of products that informed this curve
|
||||
median_first_week_sales NUMERIC(10,2), -- for scaling new launches
|
||||
median_preorder_sales NUMERIC(10,2), -- for scaling pre-order products
|
||||
median_preorder_days NUMERIC(10,2), -- median pre-order accumulation window (days)
|
||||
computed_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(brand, cat_id)
|
||||
);
|
||||
|
||||
-- Per-product daily forecasts (next 90 days, regenerated each run)
|
||||
CREATE TABLE IF NOT EXISTS product_forecasts (
|
||||
pid BIGINT NOT NULL,
|
||||
forecast_date DATE NOT NULL,
|
||||
forecast_units NUMERIC(10,2),
|
||||
forecast_revenue NUMERIC(14,4),
|
||||
lifecycle_phase TEXT, -- preorder, launch, decay, mature, slow_mover, dormant
|
||||
forecast_method TEXT, -- lifecycle_curve, exp_smoothing, velocity, zero
|
||||
confidence_lower NUMERIC(10,2),
|
||||
confidence_upper NUMERIC(10,2),
|
||||
generated_at TIMESTAMP DEFAULT NOW(),
|
||||
PRIMARY KEY (pid, forecast_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pf_date ON product_forecasts(forecast_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_pf_phase ON product_forecasts(lifecycle_phase);
|
||||
|
||||
-- Forecast run history (for monitoring)
|
||||
CREATE TABLE IF NOT EXISTS forecast_runs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
started_at TIMESTAMP NOT NULL,
|
||||
finished_at TIMESTAMP,
|
||||
status TEXT DEFAULT 'running', -- running, completed, failed
|
||||
products_forecast INT,
|
||||
phase_counts JSONB, -- {"launch": 50, "decay": 200, ...}
|
||||
curve_count INT, -- brand curves computed
|
||||
error_message TEXT,
|
||||
duration_seconds NUMERIC(10,2)
|
||||
);
|
||||
@@ -7,6 +7,7 @@ const { importProducts } = require('./import/products');
|
||||
const importOrders = require('./import/orders');
|
||||
const importPurchaseOrders = require('./import/purchase-orders');
|
||||
const importDailyDeals = require('./import/daily-deals');
|
||||
const importStockSnapshots = require('./import/stock-snapshots');
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, "../.env") });
|
||||
|
||||
@@ -16,6 +17,7 @@ const IMPORT_PRODUCTS = true;
|
||||
const IMPORT_ORDERS = true;
|
||||
const IMPORT_PURCHASE_ORDERS = true;
|
||||
const IMPORT_DAILY_DEALS = true;
|
||||
const IMPORT_STOCK_SNAPSHOTS = true;
|
||||
|
||||
// Add flag for incremental updates
|
||||
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
|
||||
@@ -38,7 +40,7 @@ const sshConfig = {
|
||||
password: process.env.PROD_DB_PASSWORD,
|
||||
database: process.env.PROD_DB_NAME,
|
||||
port: process.env.PROD_DB_PORT || 3306,
|
||||
timezone: '-05:00', // Production DB always stores times in EST (UTC-5) regardless of DST
|
||||
timezone: '-05:00', // mysql2 driver timezone — corrected at runtime via adjustDateForMySQL() in utils.js
|
||||
},
|
||||
localDbConfig: {
|
||||
// PostgreSQL config for local
|
||||
@@ -81,7 +83,8 @@ async function main() {
|
||||
IMPORT_PRODUCTS,
|
||||
IMPORT_ORDERS,
|
||||
IMPORT_PURCHASE_ORDERS,
|
||||
IMPORT_DAILY_DEALS
|
||||
IMPORT_DAILY_DEALS,
|
||||
IMPORT_STOCK_SNAPSHOTS
|
||||
].filter(Boolean).length;
|
||||
|
||||
try {
|
||||
@@ -130,10 +133,11 @@ async function main() {
|
||||
'products_enabled', $3::boolean,
|
||||
'orders_enabled', $4::boolean,
|
||||
'purchase_orders_enabled', $5::boolean,
|
||||
'daily_deals_enabled', $6::boolean
|
||||
'daily_deals_enabled', $6::boolean,
|
||||
'stock_snapshots_enabled', $7::boolean
|
||||
)
|
||||
) RETURNING id
|
||||
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS, IMPORT_DAILY_DEALS]);
|
||||
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS, IMPORT_DAILY_DEALS, IMPORT_STOCK_SNAPSHOTS]);
|
||||
importHistoryId = historyResult.rows[0].id;
|
||||
} catch (error) {
|
||||
console.error("Error creating import history record:", error);
|
||||
@@ -151,7 +155,8 @@ async function main() {
|
||||
products: null,
|
||||
orders: null,
|
||||
purchaseOrders: null,
|
||||
dailyDeals: null
|
||||
dailyDeals: null,
|
||||
stockSnapshots: null
|
||||
};
|
||||
|
||||
let totalRecordsAdded = 0;
|
||||
@@ -257,6 +262,33 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
if (IMPORT_STOCK_SNAPSHOTS) {
|
||||
try {
|
||||
const stepStart = Date.now();
|
||||
results.stockSnapshots = await importStockSnapshots(prodConnection, localConnection, INCREMENTAL_UPDATE);
|
||||
stepTimings.stockSnapshots = Math.round((Date.now() - stepStart) / 1000);
|
||||
|
||||
if (isImportCancelled) throw new Error("Import cancelled");
|
||||
completedSteps++;
|
||||
console.log('Stock snapshots import result:', results.stockSnapshots);
|
||||
|
||||
if (results.stockSnapshots?.status === 'error') {
|
||||
console.error('Stock snapshots import had an error:', results.stockSnapshots.error);
|
||||
} else {
|
||||
totalRecordsAdded += parseInt(results.stockSnapshots?.recordsAdded || 0);
|
||||
totalRecordsUpdated += parseInt(results.stockSnapshots?.recordsUpdated || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during stock snapshots import:', error);
|
||||
results.stockSnapshots = {
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
recordsAdded: 0,
|
||||
recordsUpdated: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const totalElapsedSeconds = Math.round((endTime - startTime) / 1000);
|
||||
|
||||
@@ -280,11 +312,13 @@ async function main() {
|
||||
'orders_result', COALESCE($11::jsonb, 'null'::jsonb),
|
||||
'purchase_orders_result', COALESCE($12::jsonb, 'null'::jsonb),
|
||||
'daily_deals_result', COALESCE($13::jsonb, 'null'::jsonb),
|
||||
'total_deleted', $14::integer,
|
||||
'total_skipped', $15::integer,
|
||||
'step_timings', $16::jsonb
|
||||
'stock_snapshots_enabled', $14::boolean,
|
||||
'stock_snapshots_result', COALESCE($15::jsonb, 'null'::jsonb),
|
||||
'total_deleted', $16::integer,
|
||||
'total_skipped', $17::integer,
|
||||
'step_timings', $18::jsonb
|
||||
)
|
||||
WHERE id = $17
|
||||
WHERE id = $19
|
||||
`, [
|
||||
totalElapsedSeconds,
|
||||
parseInt(totalRecordsAdded),
|
||||
@@ -299,6 +333,8 @@ async function main() {
|
||||
JSON.stringify(results.orders),
|
||||
JSON.stringify(results.purchaseOrders),
|
||||
JSON.stringify(results.dailyDeals),
|
||||
IMPORT_STOCK_SNAPSHOTS,
|
||||
JSON.stringify(results.stockSnapshots),
|
||||
totalRecordsDeleted,
|
||||
totalRecordsSkipped,
|
||||
JSON.stringify(stepTimings),
|
||||
|
||||
@@ -17,6 +17,33 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
const startTime = Date.now();
|
||||
const skippedOrders = new Set();
|
||||
const missingProducts = new Set();
|
||||
|
||||
// Map order status codes to text values (consistent with PO status mapping in purchase-orders.js)
|
||||
const orderStatusMap = {
|
||||
0: 'created',
|
||||
10: 'unfinished',
|
||||
15: 'canceled',
|
||||
16: 'combined',
|
||||
20: 'placed',
|
||||
22: 'placed_incomplete',
|
||||
30: 'canceled',
|
||||
40: 'awaiting_payment',
|
||||
50: 'awaiting_products',
|
||||
55: 'shipping_later',
|
||||
56: 'shipping_together',
|
||||
60: 'ready',
|
||||
61: 'flagged',
|
||||
62: 'fix_before_pick',
|
||||
65: 'manual_picking',
|
||||
70: 'in_pt',
|
||||
80: 'picked',
|
||||
90: 'awaiting_shipment',
|
||||
91: 'remote_wait',
|
||||
92: 'awaiting_pickup',
|
||||
93: 'fix_before_ship',
|
||||
95: 'shipped_confirmed',
|
||||
100: 'shipped'
|
||||
};
|
||||
let recordsAdded = 0;
|
||||
let recordsUpdated = 0;
|
||||
let processedCount = 0;
|
||||
@@ -31,8 +58,12 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'orders'"
|
||||
);
|
||||
const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01';
|
||||
// Adjust for mysql2 driver timezone vs MySQL server timezone mismatch
|
||||
const mysqlSyncTime = prodConnection.adjustDateForMySQL
|
||||
? prodConnection.adjustDateForMySQL(lastSyncTime)
|
||||
: lastSyncTime;
|
||||
|
||||
console.log('Orders: Using last sync time:', lastSyncTime);
|
||||
console.log('Orders: Using last sync time:', lastSyncTime, '(adjusted:', mysqlSyncTime, ')');
|
||||
|
||||
// First get count of order items - Keep MySQL compatible for production
|
||||
const [[{ total }]] = await prodConnection.query(`
|
||||
@@ -46,11 +77,6 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
AND (
|
||||
o.stamp > ?
|
||||
OR oi.stamp > ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM order_discount_items odi
|
||||
WHERE odi.order_id = o.order_id
|
||||
AND odi.pid = oi.prod_pid
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM order_tax_info oti
|
||||
JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id
|
||||
@@ -60,7 +86,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
)
|
||||
)
|
||||
` : ''}
|
||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
|
||||
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
|
||||
|
||||
totalOrderItems = total;
|
||||
console.log('Orders: Found changes:', totalOrderItems);
|
||||
@@ -85,11 +111,6 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
AND (
|
||||
o.stamp > ?
|
||||
OR oi.stamp > ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM order_discount_items odi
|
||||
WHERE odi.order_id = o.order_id
|
||||
AND odi.pid = oi.prod_pid
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM order_tax_info oti
|
||||
JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id
|
||||
@@ -99,7 +120,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
)
|
||||
)
|
||||
` : ''}
|
||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
|
||||
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
|
||||
|
||||
console.log('Orders: Found', orderItems.length, 'order items to process');
|
||||
|
||||
@@ -284,7 +305,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
new Date(order.date), // Convert to TIMESTAMP WITH TIME ZONE
|
||||
order.customer,
|
||||
toTitleCase(order.customer_name) || '',
|
||||
order.status.toString(), // Convert status to TEXT
|
||||
orderStatusMap[order.status] || order.status.toString(), // Map numeric status to text
|
||||
order.canceled,
|
||||
order.summary_discount || 0,
|
||||
order.summary_subtotal || 0,
|
||||
@@ -513,11 +534,12 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
}
|
||||
};
|
||||
|
||||
// Process all data types SEQUENTIALLY for each batch - not in parallel
|
||||
// Process all data types for each batch
|
||||
// Note: these run sequentially because they share a single PG connection
|
||||
// and each manages its own transaction
|
||||
for (let i = 0; i < orderIds.length; i += METADATA_BATCH_SIZE) {
|
||||
const batchIds = orderIds.slice(i, i + METADATA_BATCH_SIZE);
|
||||
|
||||
// Run these sequentially instead of in parallel to avoid transaction conflicts
|
||||
await processMetadataBatch(batchIds);
|
||||
await processDiscountsBatch(batchIds);
|
||||
await processTaxesBatch(batchIds);
|
||||
@@ -536,17 +558,37 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-check all products at once
|
||||
// Pre-check all products and preload cost_price into a temp table
|
||||
// This avoids joining public.products in every sub-batch query (was causing 2x slowdown)
|
||||
const allOrderPids = [...new Set(orderItems.map(item => item.prod_pid))];
|
||||
console.log('Orders: Checking', allOrderPids.length, 'unique products');
|
||||
|
||||
const [existingProducts] = allOrderPids.length > 0 ? await localConnection.query(
|
||||
"SELECT pid FROM products WHERE pid = ANY($1::bigint[])",
|
||||
"SELECT pid, cost_price FROM products WHERE pid = ANY($1::bigint[])",
|
||||
[allOrderPids]
|
||||
) : [[]];
|
||||
) : [{ rows: [] }];
|
||||
|
||||
const existingPids = new Set(existingProducts.rows.map(p => p.pid));
|
||||
|
||||
// Create temp table with product cost_price for fast lookup in sub-batch queries
|
||||
await localConnection.query(`
|
||||
DROP TABLE IF EXISTS temp_product_costs;
|
||||
CREATE TEMP TABLE temp_product_costs (
|
||||
pid BIGINT PRIMARY KEY,
|
||||
cost_price NUMERIC(14, 4)
|
||||
)
|
||||
`);
|
||||
if (existingProducts.rows.length > 0) {
|
||||
const costPids = existingProducts.rows.filter(p => p.cost_price != null).map(p => p.pid);
|
||||
const costPrices = existingProducts.rows.filter(p => p.cost_price != null).map(p => p.cost_price);
|
||||
if (costPids.length > 0) {
|
||||
await localConnection.query(`
|
||||
INSERT INTO temp_product_costs (pid, cost_price)
|
||||
SELECT * FROM UNNEST($1::bigint[], $2::numeric[])
|
||||
`, [costPids, costPrices]);
|
||||
}
|
||||
}
|
||||
|
||||
// Process in smaller batches
|
||||
for (let i = 0; i < orderIds.length; i += 2000) { // Increased from 1000 to 2000
|
||||
const batchIds = orderIds.slice(i, i + 2000);
|
||||
@@ -570,14 +612,15 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
ELSE 0
|
||||
END) as promo_discount_sum,
|
||||
COALESCE(ot.tax, 0) as total_tax,
|
||||
COALESCE(oc.costeach, oi.price * 0.5) as costeach
|
||||
COALESCE(oc.costeach, pc.cost_price, oi.price * 0.5) as costeach
|
||||
FROM temp_order_items oi
|
||||
LEFT JOIN temp_item_discounts id ON oi.order_id = id.order_id AND oi.pid = id.pid
|
||||
LEFT JOIN temp_main_discounts md ON id.order_id = md.order_id AND id.discount_id = md.discount_id
|
||||
LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
|
||||
LEFT JOIN temp_order_costs oc ON oi.order_id = oc.order_id AND oi.pid = oc.pid
|
||||
LEFT JOIN temp_product_costs pc ON oi.pid = pc.pid
|
||||
WHERE oi.order_id = ANY($1)
|
||||
GROUP BY oi.order_id, oi.pid, ot.tax, oc.costeach
|
||||
GROUP BY oi.order_id, oi.pid, ot.tax, oc.costeach, pc.cost_price
|
||||
)
|
||||
SELECT
|
||||
oi.order_id as order_number,
|
||||
@@ -587,17 +630,14 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
oi.price,
|
||||
oi.quantity,
|
||||
(
|
||||
-- Part 1: Sale Savings for the Line
|
||||
(oi.base_discount * oi.quantity)
|
||||
+
|
||||
-- Part 2: Prorated Points Discount (if applicable)
|
||||
-- Prorated Points Discount (e.g. loyalty points applied at order level)
|
||||
CASE
|
||||
WHEN om.summary_discount_subtotal > 0 AND om.summary_subtotal > 0 THEN
|
||||
COALESCE(ROUND((om.summary_discount_subtotal * (oi.price * oi.quantity)) / NULLIF(om.summary_subtotal, 0), 4), 0)
|
||||
ELSE 0
|
||||
END
|
||||
+
|
||||
-- Part 3: Specific Item-Level Discount (only if parent discount affected subtotal)
|
||||
-- Specific Item-Level Promo Discount (coupon codes, etc.)
|
||||
COALESCE(ot.promo_discount_sum, 0)
|
||||
)::NUMERIC(14, 4) as discount,
|
||||
COALESCE(ot.total_tax, 0)::NUMERIC(14, 4) as tax,
|
||||
@@ -607,10 +647,11 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
om.customer_name,
|
||||
om.status,
|
||||
om.canceled,
|
||||
COALESCE(ot.costeach, oi.price * 0.5)::NUMERIC(14, 4) as costeach
|
||||
COALESCE(ot.costeach, pc.cost_price, oi.price * 0.5)::NUMERIC(14, 4) as costeach
|
||||
FROM temp_order_items oi
|
||||
JOIN temp_order_meta om ON oi.order_id = om.order_id
|
||||
LEFT JOIN order_totals ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
|
||||
LEFT JOIN temp_product_costs pc ON oi.pid = pc.pid
|
||||
WHERE oi.order_id = ANY($1)
|
||||
ORDER BY oi.order_id, oi.pid
|
||||
`, [subBatchIds]);
|
||||
@@ -654,7 +695,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
o.shipping,
|
||||
o.customer,
|
||||
o.customer_name,
|
||||
o.status.toString(), // Convert status to TEXT
|
||||
o.status, // Already mapped to text via orderStatusMap
|
||||
o.canceled,
|
||||
o.costeach
|
||||
]);
|
||||
@@ -744,6 +785,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
||||
DROP TABLE IF EXISTS temp_order_costs;
|
||||
DROP TABLE IF EXISTS temp_main_discounts;
|
||||
DROP TABLE IF EXISTS temp_item_discounts;
|
||||
DROP TABLE IF EXISTS temp_product_costs;
|
||||
`);
|
||||
|
||||
// Commit final transaction
|
||||
|
||||
@@ -77,7 +77,6 @@ async function setupTemporaryTables(connection) {
|
||||
created_at TIMESTAMP WITH TIME ZONE,
|
||||
date_online TIMESTAMP WITH TIME ZONE,
|
||||
first_received TIMESTAMP WITH TIME ZONE,
|
||||
landing_cost_price NUMERIC(14, 4),
|
||||
barcode TEXT,
|
||||
harmonized_tariff_code TEXT,
|
||||
updated_at TIMESTAMP WITH TIME ZONE,
|
||||
@@ -172,7 +171,6 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
||||
)
|
||||
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
|
||||
END AS cost_price,
|
||||
NULL as landing_cost_price,
|
||||
s.companyname AS vendor,
|
||||
CASE
|
||||
WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber
|
||||
@@ -242,8 +240,8 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
||||
const batch = prodData.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const placeholders = batch.map((_, idx) => {
|
||||
const base = idx * 50; // 50 columns
|
||||
return `(${Array.from({ length: 50 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||
const base = idx * 49; // 49 columns
|
||||
return `(${Array.from({ length: 49 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||
}).join(',');
|
||||
|
||||
const values = batch.flatMap(row => {
|
||||
@@ -270,7 +268,6 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
||||
validateDate(row.date_created),
|
||||
validateDate(row.date_ol),
|
||||
validateDate(row.first_received),
|
||||
row.landing_cost_price,
|
||||
row.barcode,
|
||||
row.harmonized_tariff_code,
|
||||
validateDate(row.updated_at),
|
||||
@@ -308,7 +305,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
||||
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
|
||||
price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
|
||||
brand, line, subline, artist, categories, created_at, date_online, first_received,
|
||||
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible,
|
||||
barcode, harmonized_tariff_code, updated_at, visible,
|
||||
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
|
||||
weight, length, width, height, country_of_origin, location, total_sold,
|
||||
baskets, notifies, date_last_sold, shop_score, primary_iid, image, image_175, image_full, options, tags
|
||||
@@ -382,7 +379,6 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
||||
)
|
||||
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
|
||||
END AS cost_price,
|
||||
NULL as landing_cost_price,
|
||||
s.companyname AS vendor,
|
||||
CASE
|
||||
WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber
|
||||
@@ -457,8 +453,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
||||
|
||||
await withRetry(async () => {
|
||||
const placeholders = batch.map((_, idx) => {
|
||||
const base = idx * 50; // 50 columns
|
||||
return `(${Array.from({ length: 50 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||
const base = idx * 49; // 49 columns
|
||||
return `(${Array.from({ length: 49 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||
}).join(',');
|
||||
|
||||
const values = batch.flatMap(row => {
|
||||
@@ -485,7 +481,6 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
||||
validateDate(row.date_created),
|
||||
validateDate(row.date_ol),
|
||||
validateDate(row.first_received),
|
||||
row.landing_cost_price,
|
||||
row.barcode,
|
||||
row.harmonized_tariff_code,
|
||||
validateDate(row.updated_at),
|
||||
@@ -522,7 +517,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
||||
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
|
||||
price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
|
||||
brand, line, subline, artist, categories, created_at, date_online, first_received,
|
||||
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible,
|
||||
barcode, harmonized_tariff_code, updated_at, visible,
|
||||
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
|
||||
weight, length, width, height, country_of_origin, location, total_sold,
|
||||
baskets, notifies, date_last_sold, shop_score, primary_iid, image, image_175, image_full, options, tags
|
||||
@@ -547,7 +542,6 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
||||
created_at = EXCLUDED.created_at,
|
||||
date_online = EXCLUDED.date_online,
|
||||
first_received = EXCLUDED.first_received,
|
||||
landing_cost_price = EXCLUDED.landing_cost_price,
|
||||
barcode = EXCLUDED.barcode,
|
||||
harmonized_tariff_code = EXCLUDED.harmonized_tariff_code,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
@@ -675,8 +669,13 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
||||
// Setup temporary tables
|
||||
await setupTemporaryTables(localConnection);
|
||||
|
||||
// Adjust sync time for mysql2 driver timezone vs MySQL server timezone mismatch
|
||||
const mysqlSyncTime = prodConnection.adjustDateForMySQL
|
||||
? prodConnection.adjustDateForMySQL(lastSyncTime)
|
||||
: lastSyncTime;
|
||||
|
||||
// Materialize calculations into temp table
|
||||
const materializeResult = await materializeCalculations(prodConnection, localConnection, incrementalUpdate, lastSyncTime, startTime);
|
||||
const materializeResult = await materializeCalculations(prodConnection, localConnection, incrementalUpdate, mysqlSyncTime, startTime);
|
||||
|
||||
// Get the list of products that need updating
|
||||
const [products] = await localConnection.query(`
|
||||
@@ -702,7 +701,6 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
||||
t.created_at,
|
||||
t.date_online,
|
||||
t.first_received,
|
||||
t.landing_cost_price,
|
||||
t.barcode,
|
||||
t.harmonized_tariff_code,
|
||||
t.updated_at,
|
||||
@@ -742,8 +740,8 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
||||
const batch = products.rows.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const placeholders = batch.map((_, idx) => {
|
||||
const base = idx * 49; // 49 columns
|
||||
return `(${Array.from({ length: 49 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||
const base = idx * 48; // 48 columns (no primary_iid in this INSERT)
|
||||
return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||
}).join(',');
|
||||
|
||||
const values = batch.flatMap(row => {
|
||||
@@ -770,7 +768,6 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
||||
validateDate(row.created_at),
|
||||
validateDate(row.date_online),
|
||||
validateDate(row.first_received),
|
||||
row.landing_cost_price,
|
||||
row.barcode,
|
||||
row.harmonized_tariff_code,
|
||||
validateDate(row.updated_at),
|
||||
@@ -807,7 +804,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
||||
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
|
||||
price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
|
||||
brand, line, subline, artist, categories, created_at, date_online, first_received,
|
||||
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible,
|
||||
barcode, harmonized_tariff_code, updated_at, visible,
|
||||
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
|
||||
weight, length, width, height, country_of_origin, location, total_sold,
|
||||
baskets, notifies, date_last_sold, shop_score, image, image_175, image_full, options, tags
|
||||
@@ -833,7 +830,6 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
||||
created_at = EXCLUDED.created_at,
|
||||
date_online = EXCLUDED.date_online,
|
||||
first_received = EXCLUDED.first_received,
|
||||
landing_cost_price = EXCLUDED.landing_cost_price,
|
||||
barcode = EXCLUDED.barcode,
|
||||
harmonized_tariff_code = EXCLUDED.harmonized_tariff_code,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
|
||||
@@ -65,8 +65,12 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'purchase_orders'"
|
||||
);
|
||||
const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01';
|
||||
// Adjust for mysql2 driver timezone vs MySQL server timezone mismatch
|
||||
const mysqlSyncTime = prodConnection.adjustDateForMySQL
|
||||
? prodConnection.adjustDateForMySQL(lastSyncTime)
|
||||
: lastSyncTime;
|
||||
|
||||
console.log('Purchase Orders: Using last sync time:', lastSyncTime);
|
||||
console.log('Purchase Orders: Using last sync time:', lastSyncTime, '(adjusted:', mysqlSyncTime, ')');
|
||||
|
||||
// Create temp tables for processing
|
||||
await localConnection.query(`
|
||||
@@ -254,7 +258,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
OR p.date_estin > ?
|
||||
)
|
||||
` : ''}
|
||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
|
||||
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
|
||||
|
||||
const totalPOs = poCount[0].total;
|
||||
console.log(`Found ${totalPOs} relevant purchase orders`);
|
||||
@@ -291,7 +295,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
` : ''}
|
||||
ORDER BY p.po_id
|
||||
LIMIT ${PO_BATCH_SIZE} OFFSET ${offset}
|
||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
|
||||
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
|
||||
|
||||
if (poList.length === 0) {
|
||||
allPOsProcessed = true;
|
||||
@@ -426,7 +430,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
OR r.date_created > ?
|
||||
)
|
||||
` : ''}
|
||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime] : []);
|
||||
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime] : []);
|
||||
|
||||
const totalReceivings = receivingCount[0].total;
|
||||
console.log(`Found ${totalReceivings} relevant receivings`);
|
||||
@@ -463,7 +467,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
` : ''}
|
||||
ORDER BY r.receiving_id
|
||||
LIMIT ${PO_BATCH_SIZE} OFFSET ${offset}
|
||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime] : []);
|
||||
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime] : []);
|
||||
|
||||
if (receivingList.length === 0) {
|
||||
allReceivingsProcessed = true;
|
||||
|
||||
188
inventory-server/scripts/import/stock-snapshots.js
Normal file
188
inventory-server/scripts/import/stock-snapshots.js
Normal file
@@ -0,0 +1,188 @@
|
||||
const { outputProgress, formatElapsedTime, calculateRate } = require('../metrics-new/utils/progress');
|
||||
|
||||
const BATCH_SIZE = 5000;
|
||||
|
||||
/**
|
||||
* Imports daily stock snapshots from MySQL's snap_product_value table to PostgreSQL.
|
||||
* This provides historical end-of-day stock quantities per product, dating back to 2012.
|
||||
*
|
||||
* MySQL source table: snap_product_value (date, pid, count, pending, value)
|
||||
* - date: snapshot date (typically yesterday's date, recorded daily by cron)
|
||||
* - pid: product ID
|
||||
* - count: end-of-day stock quantity (sum of product_inventory.count)
|
||||
* - pending: pending/on-order quantity
|
||||
* - value: total inventory value at cost (sum of costeach * count)
|
||||
*
|
||||
* PostgreSQL target table: stock_snapshots (snapshot_date, pid, stock_quantity, pending_quantity, stock_value)
|
||||
*
|
||||
* @param {object} prodConnection - MySQL connection to production DB
|
||||
* @param {object} localConnection - PostgreSQL connection wrapper
|
||||
* @param {boolean} incrementalUpdate - If true, only fetch new snapshots since last import
|
||||
* @returns {object} Import statistics
|
||||
*/
|
||||
async function importStockSnapshots(prodConnection, localConnection, incrementalUpdate = true) {
|
||||
const startTime = Date.now();
|
||||
|
||||
outputProgress({
|
||||
status: 'running',
|
||||
operation: 'Stock snapshots import',
|
||||
message: 'Starting stock snapshots import...',
|
||||
current: 0,
|
||||
total: 0,
|
||||
elapsed: formatElapsedTime(startTime)
|
||||
});
|
||||
|
||||
// Ensure target table exists
|
||||
await localConnection.query(`
|
||||
CREATE TABLE IF NOT EXISTS stock_snapshots (
|
||||
snapshot_date DATE NOT NULL,
|
||||
pid BIGINT NOT NULL,
|
||||
stock_quantity INT NOT NULL DEFAULT 0,
|
||||
pending_quantity INT NOT NULL DEFAULT 0,
|
||||
stock_value NUMERIC(14, 4) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (snapshot_date, pid)
|
||||
)
|
||||
`);
|
||||
|
||||
// Create index for efficient lookups by pid
|
||||
await localConnection.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_snapshots_pid ON stock_snapshots (pid)
|
||||
`);
|
||||
|
||||
// Determine the start date for the import
|
||||
let startDate = '2020-01-01'; // Default: match the orders/snapshots date range
|
||||
if (incrementalUpdate) {
|
||||
const [result] = await localConnection.query(`
|
||||
SELECT MAX(snapshot_date)::text AS max_date FROM stock_snapshots
|
||||
`);
|
||||
if (result.rows[0]?.max_date) {
|
||||
// Start from the day after the last imported date
|
||||
startDate = result.rows[0].max_date;
|
||||
}
|
||||
}
|
||||
|
||||
outputProgress({
|
||||
status: 'running',
|
||||
operation: 'Stock snapshots import',
|
||||
message: `Fetching stock snapshots from MySQL since ${startDate}...`,
|
||||
current: 0,
|
||||
total: 0,
|
||||
elapsed: formatElapsedTime(startTime)
|
||||
});
|
||||
|
||||
// Count total rows to import
|
||||
const [countResult] = await prodConnection.query(
|
||||
`SELECT COUNT(*) AS total FROM snap_product_value WHERE date > ?`,
|
||||
[startDate]
|
||||
);
|
||||
const totalRows = countResult[0].total;
|
||||
|
||||
if (totalRows === 0) {
|
||||
outputProgress({
|
||||
status: 'complete',
|
||||
operation: 'Stock snapshots import',
|
||||
message: 'No new stock snapshots to import',
|
||||
current: 0,
|
||||
total: 0,
|
||||
elapsed: formatElapsedTime(startTime)
|
||||
});
|
||||
return { recordsAdded: 0, recordsUpdated: 0, status: 'complete' };
|
||||
}
|
||||
|
||||
outputProgress({
|
||||
status: 'running',
|
||||
operation: 'Stock snapshots import',
|
||||
message: `Found ${totalRows.toLocaleString()} stock snapshot rows to import`,
|
||||
current: 0,
|
||||
total: totalRows,
|
||||
elapsed: formatElapsedTime(startTime)
|
||||
});
|
||||
|
||||
// Process in batches using date-based pagination (more efficient than OFFSET)
|
||||
let processedRows = 0;
|
||||
let recordsAdded = 0;
|
||||
let currentDate = startDate;
|
||||
|
||||
while (processedRows < totalRows) {
|
||||
// Fetch a batch of dates
|
||||
const [dateBatch] = await prodConnection.query(
|
||||
`SELECT DISTINCT date FROM snap_product_value
|
||||
WHERE date > ? ORDER BY date LIMIT 10`,
|
||||
[currentDate]
|
||||
);
|
||||
|
||||
if (dateBatch.length === 0) break;
|
||||
|
||||
const dates = dateBatch.map(r => r.date);
|
||||
const lastDate = dates[dates.length - 1];
|
||||
|
||||
// Fetch all rows for these dates
|
||||
const [rows] = await prodConnection.query(
|
||||
`SELECT date, pid, count AS stock_quantity, pending AS pending_quantity, value AS stock_value
|
||||
FROM snap_product_value
|
||||
WHERE date > ? AND date <= ?
|
||||
ORDER BY date, pid`,
|
||||
[currentDate, lastDate]
|
||||
);
|
||||
|
||||
if (rows.length === 0) break;
|
||||
|
||||
// Batch insert into PostgreSQL using UNNEST for efficiency
|
||||
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
||||
const batch = rows.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const dates = batch.map(r => r.date);
|
||||
const pids = batch.map(r => r.pid);
|
||||
const quantities = batch.map(r => r.stock_quantity);
|
||||
const pending = batch.map(r => r.pending_quantity);
|
||||
const values = batch.map(r => r.stock_value);
|
||||
|
||||
try {
|
||||
const [result] = await localConnection.query(`
|
||||
INSERT INTO stock_snapshots (snapshot_date, pid, stock_quantity, pending_quantity, stock_value)
|
||||
SELECT * FROM UNNEST(
|
||||
$1::date[], $2::bigint[], $3::int[], $4::int[], $5::numeric[]
|
||||
)
|
||||
ON CONFLICT (snapshot_date, pid) DO UPDATE SET
|
||||
stock_quantity = EXCLUDED.stock_quantity,
|
||||
pending_quantity = EXCLUDED.pending_quantity,
|
||||
stock_value = EXCLUDED.stock_value
|
||||
`, [dates, pids, quantities, pending, values]);
|
||||
|
||||
recordsAdded += batch.length;
|
||||
} catch (err) {
|
||||
console.error(`Error inserting batch at offset ${i} (date range ending ${currentDate}):`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
processedRows += rows.length;
|
||||
currentDate = lastDate;
|
||||
|
||||
outputProgress({
|
||||
status: 'running',
|
||||
operation: 'Stock snapshots import',
|
||||
message: `Imported ${processedRows.toLocaleString()} / ${totalRows.toLocaleString()} rows (through ${currentDate})`,
|
||||
current: processedRows,
|
||||
total: totalRows,
|
||||
elapsed: formatElapsedTime(startTime),
|
||||
rate: calculateRate(processedRows, startTime)
|
||||
});
|
||||
}
|
||||
|
||||
outputProgress({
|
||||
status: 'complete',
|
||||
operation: 'Stock snapshots import',
|
||||
message: `Stock snapshots import complete: ${recordsAdded.toLocaleString()} rows`,
|
||||
current: processedRows,
|
||||
total: totalRows,
|
||||
elapsed: formatElapsedTime(startTime)
|
||||
});
|
||||
|
||||
return {
|
||||
recordsAdded,
|
||||
recordsUpdated: 0,
|
||||
status: 'complete'
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = importStockSnapshots;
|
||||
@@ -48,6 +48,37 @@ async function setupConnections(sshConfig) {
|
||||
stream: tunnel.stream,
|
||||
});
|
||||
|
||||
// Detect MySQL server timezone and calculate correction for the driver timezone mismatch.
|
||||
// The mysql2 driver is configured with timezone: '-05:00' (EST), but the MySQL server
|
||||
// may be in a different timezone (e.g., America/Chicago = CST/CDT). When the driver
|
||||
// formats a JS Date as EST and MySQL interprets it in its own timezone, DATETIME
|
||||
// comparisons can be off. This correction adjusts Date objects before they're passed
|
||||
// to MySQL queries so the formatted string matches the server's local time.
|
||||
const [[{ utcDiffSec }]] = await prodConnection.query(
|
||||
"SELECT TIMESTAMPDIFF(SECOND, NOW(), UTC_TIMESTAMP()) as utcDiffSec"
|
||||
);
|
||||
const mysqlOffsetMs = -utcDiffSec * 1000; // MySQL UTC offset in ms (e.g., -21600000 for CST)
|
||||
const driverOffsetMs = -5 * 3600 * 1000; // Driver's -05:00 in ms (-18000000)
|
||||
const tzCorrectionMs = driverOffsetMs - mysqlOffsetMs;
|
||||
// CST (winter): -18000000 - (-21600000) = +3600000 (1 hour correction needed)
|
||||
// CDT (summer): -18000000 - (-18000000) = 0 (no correction needed)
|
||||
|
||||
if (tzCorrectionMs !== 0) {
|
||||
console.log(`MySQL timezone correction: ${tzCorrectionMs / 1000}s (server offset: ${utcDiffSec}s from UTC)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts a Date/timestamp for the mysql2 driver timezone mismatch before
|
||||
* passing it as a query parameter to MySQL. This ensures that the string
|
||||
* mysql2 generates matches the timezone that DATETIME values are stored in.
|
||||
*/
|
||||
function adjustDateForMySQL(date) {
|
||||
if (!date || tzCorrectionMs === 0) return date;
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return new Date(d.getTime() - tzCorrectionMs);
|
||||
}
|
||||
prodConnection.adjustDateForMySQL = adjustDateForMySQL;
|
||||
|
||||
// Setup PostgreSQL connection pool for local
|
||||
const localPool = new Pool(sshConfig.localDbConfig);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ BEGIN
|
||||
p.visible as is_visible, p.replenishable,
|
||||
COALESCE(p.price, 0.00) as current_price, COALESCE(p.regular_price, 0.00) as current_regular_price,
|
||||
COALESCE(p.cost_price, 0.00) as current_cost_price,
|
||||
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as current_effective_cost, -- Use landing if available, else cost
|
||||
COALESCE(p.cost_price, 0.00) as current_effective_cost,
|
||||
p.stock_quantity as current_stock, -- Use actual current stock for forecast base
|
||||
p.created_at, p.first_received, p.date_last_sold,
|
||||
p.moq,
|
||||
@@ -214,7 +214,7 @@ BEGIN
|
||||
-- Final INSERT/UPDATE statement using all the prepared CTEs
|
||||
INSERT INTO public.product_metrics (
|
||||
pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable,
|
||||
current_price, current_regular_price, current_cost_price, current_landing_cost_price,
|
||||
current_price, current_regular_price, current_cost_price,
|
||||
current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
|
||||
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
|
||||
date_created, date_first_received, date_last_received, date_first_sold, date_last_sold, age_days,
|
||||
@@ -242,7 +242,7 @@ BEGIN
|
||||
SELECT
|
||||
-- Select columns in order, joining all CTEs by pid
|
||||
ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.replenishable,
|
||||
ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost,
|
||||
ci.current_price, ci.current_regular_price, ci.current_cost_price,
|
||||
ci.current_stock, (ci.current_stock * COALESCE(ci.current_effective_cost, 0.00))::numeric(12,2), (ci.current_stock * COALESCE(ci.current_price, 0.00))::numeric(12,2), (ci.current_stock * COALESCE(ci.current_regular_price, 0.00))::numeric(12,2),
|
||||
COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00)::numeric(12,2), (COALESCE(ooi.on_order_qty, 0) * COALESCE(ci.current_price, 0.00))::numeric(12,2), ooi.earliest_expected_date,
|
||||
|
||||
@@ -415,7 +415,7 @@ BEGIN
|
||||
-- *** IMPORTANT: List ALL columns here, ensuring order matches INSERT list ***
|
||||
-- Update ALL columns to ensure entire row is refreshed
|
||||
last_calculated = EXCLUDED.last_calculated, sku = EXCLUDED.sku, title = EXCLUDED.title, brand = EXCLUDED.brand, vendor = EXCLUDED.vendor, image_url = EXCLUDED.image_url, is_visible = EXCLUDED.is_visible, is_replenishable = EXCLUDED.is_replenishable,
|
||||
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price,
|
||||
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price,
|
||||
current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross,
|
||||
on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date,
|
||||
date_created = EXCLUDED.date_created, date_first_received = EXCLUDED.date_first_received, date_last_received = EXCLUDED.date_last_received, date_first_sold = EXCLUDED.date_first_sold, date_last_sold = EXCLUDED.date_last_sold, age_days = EXCLUDED.age_days,
|
||||
|
||||
@@ -10,7 +10,7 @@ DECLARE
|
||||
_date DATE;
|
||||
_count INT;
|
||||
_total_records INT := 0;
|
||||
_begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2024-01-01'); -- Starting point for data rebuild
|
||||
_begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2020-01-01'); -- Starting point: captures all historical order data
|
||||
_end_date DATE := CURRENT_DATE;
|
||||
BEGIN
|
||||
RAISE NOTICE 'Beginning daily snapshots rebuild from % to %. Starting at %', _begin_date, _end_date, _start_time;
|
||||
@@ -36,7 +36,13 @@ BEGIN
|
||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
|
||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted,
|
||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
|
||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs,
|
||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN
|
||||
COALESCE(
|
||||
o.costeach,
|
||||
get_weighted_avg_cost(p.pid, o.date::date),
|
||||
p.cost_price
|
||||
) * o.quantity
|
||||
ELSE 0 END), 0.00) AS cogs,
|
||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue,
|
||||
|
||||
-- Aggregate Returns (Quantity < 0 or Status = Returned)
|
||||
@@ -63,15 +69,17 @@ BEGIN
|
||||
GROUP BY r.pid
|
||||
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
|
||||
),
|
||||
-- Get stock quantities for the day - note this is approximate since we're using current products data
|
||||
-- Use historical stock from stock_snapshots when available,
|
||||
-- falling back to current stock from products table
|
||||
StockData AS (
|
||||
SELECT
|
||||
p.pid,
|
||||
p.stock_quantity,
|
||||
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as effective_cost_price,
|
||||
COALESCE(ss.stock_quantity, p.stock_quantity) AS stock_quantity,
|
||||
COALESCE(ss.stock_value, p.stock_quantity * COALESCE(p.cost_price, 0.00)) AS stock_value,
|
||||
COALESCE(p.price, 0.00) as current_price,
|
||||
COALESCE(p.regular_price, 0.00) as current_regular_price
|
||||
FROM public.products p
|
||||
LEFT JOIN stock_snapshots ss ON p.pid = ss.pid AND ss.snapshot_date = _date
|
||||
)
|
||||
INSERT INTO public.daily_product_snapshots (
|
||||
snapshot_date,
|
||||
@@ -99,9 +107,9 @@ BEGIN
|
||||
_date AS snapshot_date,
|
||||
COALESCE(sd.pid, rd.pid) AS pid,
|
||||
sd.sku,
|
||||
-- Use current stock as approximation, since historical stock data may not be available
|
||||
-- Historical stock from stock_snapshots, falls back to current stock
|
||||
s.stock_quantity AS eod_stock_quantity,
|
||||
s.stock_quantity * s.effective_cost_price AS eod_stock_cost,
|
||||
s.stock_value AS eod_stock_cost,
|
||||
s.stock_quantity * s.current_price AS eod_stock_retail,
|
||||
s.stock_quantity * s.current_regular_price AS eod_stock_gross,
|
||||
(s.stock_quantity <= 0) AS stockout_flag,
|
||||
@@ -111,10 +119,10 @@ BEGIN
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00),
|
||||
COALESCE(sd.discounts, 0.00),
|
||||
COALESCE(sd.returns_revenue, 0.00),
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue,
|
||||
COALESCE(sd.cogs, 0.00),
|
||||
COALESCE(sd.gross_regular_revenue, 0.00),
|
||||
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
|
||||
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
|
||||
-- Receiving metrics
|
||||
COALESCE(rd.units_received, 0),
|
||||
COALESCE(rd.cost_received, 0.00),
|
||||
|
||||
@@ -23,21 +23,21 @@ BEGIN
|
||||
-- Only include products with valid sales data in each time period
|
||||
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
|
||||
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
||||
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||
SUM(COALESCE(pm.revenue_7d, 0)) AS revenue_7d,
|
||||
|
||||
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
|
||||
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
||||
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
|
||||
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
|
||||
SUM(COALESCE(pm.revenue_30d, 0)) AS revenue_30d,
|
||||
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||
|
||||
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
|
||||
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
||||
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
||||
SUM(COALESCE(pm.revenue_365d, 0)) AS revenue_365d,
|
||||
|
||||
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
|
||||
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
||||
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
|
||||
SUM(COALESCE(pm.lifetime_revenue, 0)) AS lifetime_revenue
|
||||
FROM public.product_metrics pm
|
||||
JOIN public.products p ON pm.pid = p.pid
|
||||
GROUP BY brand_group
|
||||
|
||||
@@ -28,8 +28,8 @@ BEGIN
|
||||
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
||||
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
|
||||
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
|
||||
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
||||
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
||||
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
||||
@@ -38,58 +38,56 @@ BEGIN
|
||||
JOIN public.product_metrics pm ON pc.pid = pm.pid
|
||||
GROUP BY pc.cat_id
|
||||
),
|
||||
-- Calculate rolled-up metrics (including all descendant categories)
|
||||
-- Map each category to ALL distinct products in it or any descendant.
|
||||
-- Uses the path array from category_hierarchy: for product P in category C,
|
||||
-- P contributes to C and every ancestor in C's path.
|
||||
-- DISTINCT ensures each (ancestor, pid) pair appears only once, preventing
|
||||
-- double-counting when a product belongs to multiple categories under the same parent.
|
||||
CategoryProducts AS (
|
||||
SELECT DISTINCT
|
||||
ancestor_cat_id,
|
||||
pc.pid
|
||||
FROM public.product_categories pc
|
||||
JOIN category_hierarchy ch ON pc.cat_id = ch.cat_id
|
||||
CROSS JOIN LATERAL unnest(ch.path) AS ancestor_cat_id
|
||||
),
|
||||
-- Calculate rolled-up metrics using deduplicated product sets
|
||||
RolledUpMetrics AS (
|
||||
SELECT
|
||||
ch.cat_id,
|
||||
-- Sum metrics from this category and all its descendants
|
||||
SUM(dcm.product_count) AS product_count,
|
||||
SUM(dcm.active_product_count) AS active_product_count,
|
||||
SUM(dcm.replenishable_product_count) AS replenishable_product_count,
|
||||
SUM(dcm.current_stock_units) AS current_stock_units,
|
||||
SUM(dcm.current_stock_cost) AS current_stock_cost,
|
||||
SUM(dcm.current_stock_retail) AS current_stock_retail,
|
||||
SUM(dcm.sales_7d) AS sales_7d,
|
||||
SUM(dcm.revenue_7d) AS revenue_7d,
|
||||
SUM(dcm.sales_30d) AS sales_30d,
|
||||
SUM(dcm.revenue_30d) AS revenue_30d,
|
||||
SUM(dcm.cogs_30d) AS cogs_30d,
|
||||
SUM(dcm.profit_30d) AS profit_30d,
|
||||
SUM(dcm.sales_365d) AS sales_365d,
|
||||
SUM(dcm.revenue_365d) AS revenue_365d,
|
||||
SUM(dcm.lifetime_sales) AS lifetime_sales,
|
||||
SUM(dcm.lifetime_revenue) AS lifetime_revenue
|
||||
FROM category_hierarchy ch
|
||||
LEFT JOIN DirectCategoryMetrics dcm ON
|
||||
dcm.cat_id = ch.cat_id OR
|
||||
dcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
|
||||
GROUP BY ch.cat_id
|
||||
cp.ancestor_cat_id AS cat_id,
|
||||
COUNT(DISTINCT cp.pid) AS product_count,
|
||||
COUNT(DISTINCT CASE WHEN pm.is_visible THEN cp.pid END) AS active_product_count,
|
||||
COUNT(DISTINCT CASE WHEN pm.is_replenishable THEN cp.pid END) AS replenishable_product_count,
|
||||
SUM(pm.current_stock) AS current_stock_units,
|
||||
SUM(pm.current_stock_cost) AS current_stock_cost,
|
||||
SUM(pm.current_stock_retail) AS current_stock_retail,
|
||||
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
||||
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
||||
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
||||
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
||||
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
||||
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
|
||||
FROM CategoryProducts cp
|
||||
JOIN public.product_metrics pm ON cp.pid = pm.pid
|
||||
GROUP BY cp.ancestor_cat_id
|
||||
),
|
||||
PreviousPeriodCategoryMetrics AS (
|
||||
-- Get previous period metrics for growth calculation
|
||||
-- Previous period rolled up using same deduplicated product sets
|
||||
RolledUpPreviousPeriod AS (
|
||||
SELECT
|
||||
pc.cat_id,
|
||||
cp.ancestor_cat_id AS cat_id,
|
||||
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
|
||||
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
|
||||
THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
|
||||
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
|
||||
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
|
||||
THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
|
||||
FROM public.daily_product_snapshots dps
|
||||
JOIN public.product_categories pc ON dps.pid = pc.pid
|
||||
GROUP BY pc.cat_id
|
||||
),
|
||||
RolledUpPreviousPeriod AS (
|
||||
-- Calculate rolled-up previous period metrics
|
||||
SELECT
|
||||
ch.cat_id,
|
||||
SUM(ppcm.sales_prev_30d) AS sales_prev_30d,
|
||||
SUM(ppcm.revenue_prev_30d) AS revenue_prev_30d
|
||||
FROM category_hierarchy ch
|
||||
LEFT JOIN PreviousPeriodCategoryMetrics ppcm ON
|
||||
ppcm.cat_id = ch.cat_id OR
|
||||
ppcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
|
||||
GROUP BY ch.cat_id
|
||||
FROM CategoryProducts cp
|
||||
JOIN public.daily_product_snapshots dps ON cp.pid = dps.pid
|
||||
GROUP BY cp.ancestor_cat_id
|
||||
),
|
||||
AllCategories AS (
|
||||
-- Ensure all categories are included
|
||||
|
||||
@@ -24,21 +24,21 @@ BEGIN
|
||||
-- Only include products with valid sales data in each time period
|
||||
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
|
||||
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
||||
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||
SUM(COALESCE(pm.revenue_7d, 0)) AS revenue_7d,
|
||||
|
||||
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
|
||||
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
||||
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
|
||||
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
|
||||
SUM(COALESCE(pm.revenue_30d, 0)) AS revenue_30d,
|
||||
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||
|
||||
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
|
||||
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
||||
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
||||
SUM(COALESCE(pm.revenue_365d, 0)) AS revenue_365d,
|
||||
|
||||
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
|
||||
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
||||
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
|
||||
SUM(COALESCE(pm.lifetime_revenue, 0)) AS lifetime_revenue
|
||||
FROM public.product_metrics pm
|
||||
JOIN public.products p ON pm.pid = p.pid
|
||||
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
|
||||
@@ -72,7 +72,7 @@ BEGIN
|
||||
END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs
|
||||
FROM public.purchase_orders po
|
||||
-- Join to receivings table to find when items were received
|
||||
LEFT JOIN public.receivings r ON r.pid = po.pid
|
||||
LEFT JOIN public.receivings r ON r.pid = po.pid AND r.supplier_id = po.supplier_id
|
||||
WHERE po.vendor IS NOT NULL AND po.vendor <> ''
|
||||
AND po.date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year
|
||||
AND po.status = 'done' -- Only calculate lead time on completed POs
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Migration: Map existing numeric order statuses to text values
|
||||
-- Run this ONCE on the production PostgreSQL database after deploying the updated orders import.
|
||||
-- This updates ~2.88M rows. On a busy system, consider running during low-traffic hours.
|
||||
-- The WHERE clause ensures idempotency - only rows with numeric statuses are updated.
|
||||
|
||||
UPDATE orders SET status = CASE status
|
||||
WHEN '0' THEN 'created'
|
||||
WHEN '10' THEN 'unfinished'
|
||||
WHEN '15' THEN 'canceled'
|
||||
WHEN '16' THEN 'combined'
|
||||
WHEN '20' THEN 'placed'
|
||||
WHEN '22' THEN 'placed_incomplete'
|
||||
WHEN '30' THEN 'canceled'
|
||||
WHEN '40' THEN 'awaiting_payment'
|
||||
WHEN '50' THEN 'awaiting_products'
|
||||
WHEN '55' THEN 'shipping_later'
|
||||
WHEN '56' THEN 'shipping_together'
|
||||
WHEN '60' THEN 'ready'
|
||||
WHEN '61' THEN 'flagged'
|
||||
WHEN '62' THEN 'fix_before_pick'
|
||||
WHEN '65' THEN 'manual_picking'
|
||||
WHEN '70' THEN 'in_pt'
|
||||
WHEN '80' THEN 'picked'
|
||||
WHEN '90' THEN 'awaiting_shipment'
|
||||
WHEN '91' THEN 'remote_wait'
|
||||
WHEN '92' THEN 'awaiting_pickup'
|
||||
WHEN '93' THEN 'fix_before_ship'
|
||||
WHEN '95' THEN 'shipped_confirmed'
|
||||
WHEN '100' THEN 'shipped'
|
||||
ELSE status
|
||||
END
|
||||
WHERE status ~ '^\d+$'; -- Only update rows that still have numeric statuses
|
||||
|
||||
-- Verify the migration
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM orders
|
||||
GROUP BY status
|
||||
ORDER BY count DESC;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Migration 002: Fix discount double-counting in orders
|
||||
--
|
||||
-- PROBLEM: The orders import was calculating discount as:
|
||||
-- discount = (prod_price_reg - prod_price) * quantity <-- "sale savings" (WRONG)
|
||||
-- + prorated points discount
|
||||
-- + item-level promo discounts
|
||||
--
|
||||
-- Since `price` in the orders table already IS the sale price (prod_price, not prod_price_reg),
|
||||
-- the "sale savings" component double-counted the markdown. This resulted in inflated discounts
|
||||
-- and near-zero net_revenue for products sold on sale.
|
||||
--
|
||||
-- Example: Product with regular_price=$30, sale_price=$15, qty=2
|
||||
-- BEFORE (buggy): discount = ($30-$15)*2 + 0 + 0 = $30.00
|
||||
-- net_revenue = $15*2 - $30 = $0.00 (WRONG!)
|
||||
-- AFTER (fixed): discount = 0 + 0 + 0 = $0.00
|
||||
-- net_revenue = $15*2 - $0 = $30.00 (CORRECT!)
|
||||
--
|
||||
-- FIX: This cannot be fixed with a pure SQL migration because PostgreSQL doesn't store
|
||||
-- prod_price_reg. The discount column has the inflated value baked in, and we can't
|
||||
-- decompose which portion was the base_discount vs actual promo discounts.
|
||||
--
|
||||
-- REQUIRED ACTION: Run a FULL (non-incremental) orders re-import after deploying the
|
||||
-- fixed orders.js. This will recalculate all discounts using the corrected formula.
|
||||
--
|
||||
-- Steps:
|
||||
-- 1. Deploy updated orders.js (base_discount removed from discount calculation)
|
||||
-- 2. Run: node scripts/import/orders.js --full
|
||||
-- (or trigger a full sync through whatever mechanism is used)
|
||||
-- 3. After re-import, run the daily snapshots rebuild to propagate corrected revenue:
|
||||
-- psql -f scripts/metrics-new/backfill/rebuild_daily_snapshots.sql
|
||||
-- 4. Re-run metrics calculation:
|
||||
-- node scripts/metrics-new/calculate-metrics-new.js
|
||||
--
|
||||
-- VERIFICATION: After re-import, check the previously-affected products:
|
||||
SELECT
|
||||
o.pid,
|
||||
p.title,
|
||||
o.order_number,
|
||||
o.price,
|
||||
o.quantity,
|
||||
o.discount,
|
||||
(o.price * o.quantity) as gross_revenue,
|
||||
(o.price * o.quantity - o.discount) as net_revenue
|
||||
FROM orders o
|
||||
JOIN products p ON o.pid = p.pid
|
||||
WHERE o.pid IN (624756, 614513)
|
||||
ORDER BY o.date DESC
|
||||
LIMIT 10;
|
||||
|
||||
-- Expected: discount should be 0 (or small promo amount) for regular sales,
|
||||
-- and net_revenue should be close to gross_revenue.
|
||||
@@ -1,75 +1,109 @@
|
||||
-- Description: Calculates and updates daily aggregated product data for recent days.
|
||||
-- Uses UPSERT (INSERT ON CONFLICT UPDATE) for idempotency.
|
||||
-- Description: Calculates and updates daily aggregated product data.
|
||||
-- Self-healing: detects gaps (missing snapshots), stale data (snapshot
|
||||
-- aggregates that don't match source tables after backfills), and always
|
||||
-- reprocesses recent days to pick up new orders and data corrections.
|
||||
-- Dependencies: Core import tables (products, orders, purchase_orders), calculate_status table.
|
||||
-- Frequency: Hourly (Run ~5-10 minutes after hourly data import completes).
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
_module_name TEXT := 'daily_snapshots';
|
||||
_start_time TIMESTAMPTZ := clock_timestamp(); -- Time execution started
|
||||
_last_calc_time TIMESTAMPTZ;
|
||||
_target_date DATE; -- Will be set in the loop
|
||||
_start_time TIMESTAMPTZ := clock_timestamp();
|
||||
_target_date DATE;
|
||||
_total_records INT := 0;
|
||||
_has_orders BOOLEAN := FALSE;
|
||||
_process_days INT := 5; -- Number of days to check/process (today plus previous 4 days)
|
||||
_day_counter INT;
|
||||
_missing_days INT[] := ARRAY[]::INT[]; -- Array to store days with missing or incomplete data
|
||||
_days_processed INT := 0;
|
||||
_max_backfill_days INT := 90; -- Safety cap: max days to backfill per run
|
||||
_recent_recheck_days INT := 2; -- Always reprocess this many recent days (today + yesterday)
|
||||
_latest_snapshot DATE;
|
||||
_backfill_start DATE;
|
||||
BEGIN
|
||||
-- Get the timestamp before the last successful run of this module
|
||||
SELECT last_calculation_timestamp INTO _last_calc_time
|
||||
FROM public.calculate_status
|
||||
WHERE module_name = _module_name;
|
||||
|
||||
RAISE NOTICE 'Running % script. Start Time: %', _module_name, _start_time;
|
||||
|
||||
-- First, check which days need processing by comparing orders data with snapshot data
|
||||
FOR _day_counter IN 0..(_process_days-1) LOOP
|
||||
_target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day');
|
||||
-- Find the latest existing snapshot date (for logging only)
|
||||
SELECT MAX(snapshot_date) INTO _latest_snapshot
|
||||
FROM public.daily_product_snapshots;
|
||||
|
||||
-- Check if this date needs updating by comparing orders to snapshot data
|
||||
-- If the date has orders but not enough snapshots, or if snapshots show zero sales but orders exist, it's incomplete
|
||||
SELECT
|
||||
CASE WHEN (
|
||||
-- We have orders for this date but not enough snapshots, or snapshots with wrong total
|
||||
(EXISTS (SELECT 1 FROM public.orders WHERE date::date = _target_date) AND
|
||||
(
|
||||
-- No snapshots exist for this date
|
||||
NOT EXISTS (SELECT 1 FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) OR
|
||||
-- Or snapshots show zero sales but orders exist
|
||||
(SELECT COALESCE(SUM(units_sold), 0) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) = 0 OR
|
||||
-- Or the count of snapshot records is significantly less than distinct products in orders
|
||||
(SELECT COUNT(*) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) <
|
||||
(SELECT COUNT(DISTINCT pid) FROM public.orders WHERE date::date = _target_date) * 0.8
|
||||
)
|
||||
)
|
||||
) THEN TRUE ELSE FALSE END
|
||||
INTO _has_orders;
|
||||
-- Always scan the full backfill window to catch holes in the middle,
|
||||
-- not just gaps at the end. The gap fill and stale detection queries
|
||||
-- need to see the entire range to find missing or outdated snapshots.
|
||||
_backfill_start := CURRENT_DATE - _max_backfill_days;
|
||||
|
||||
IF _has_orders THEN
|
||||
-- This day needs processing - add to our array
|
||||
_missing_days := _missing_days || _day_counter;
|
||||
RAISE NOTICE 'Day % needs updating (incomplete or missing data)', _target_date;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- If no days need updating, exit early
|
||||
IF array_length(_missing_days, 1) IS NULL THEN
|
||||
RAISE NOTICE 'No days need updating - all snapshot data appears complete';
|
||||
|
||||
-- Still update the calculate_status to record this run
|
||||
UPDATE public.calculate_status
|
||||
SET last_calculation_timestamp = _start_time
|
||||
WHERE module_name = _module_name;
|
||||
|
||||
RETURN;
|
||||
IF _latest_snapshot IS NULL THEN
|
||||
RAISE NOTICE 'No existing snapshots found. Backfilling up to % days.', _max_backfill_days;
|
||||
ELSE
|
||||
RAISE NOTICE 'Latest snapshot: %. Scanning from % for gaps and stale data.', _latest_snapshot, _backfill_start;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Need to update % days with missing or incomplete data', array_length(_missing_days, 1);
|
||||
-- Process all dates that need snapshots:
|
||||
-- 1. Gap fill: dates with orders/receivings but no snapshots (older than recent window)
|
||||
-- 2. Stale detection: existing snapshots where aggregates don't match source data
|
||||
-- (catches backfilled imports that arrived after snapshot was calculated)
|
||||
-- 3. Recent recheck: last N days always reprocessed (picks up new orders, corrections)
|
||||
FOR _target_date IN
|
||||
SELECT d FROM (
|
||||
-- Gap fill: find dates with activity but missing snapshots
|
||||
SELECT activity_dates.d
|
||||
FROM (
|
||||
SELECT DISTINCT date::date AS d FROM public.orders
|
||||
WHERE date::date >= _backfill_start AND date::date < CURRENT_DATE - _recent_recheck_days
|
||||
UNION
|
||||
SELECT DISTINCT received_date::date AS d FROM public.receivings
|
||||
WHERE received_date::date >= _backfill_start AND received_date::date < CURRENT_DATE - _recent_recheck_days
|
||||
) activity_dates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.daily_product_snapshots dps WHERE dps.snapshot_date = activity_dates.d
|
||||
)
|
||||
UNION
|
||||
-- Stale detection: compare snapshot aggregates against source tables
|
||||
SELECT snap_agg.snapshot_date AS d
|
||||
FROM (
|
||||
SELECT snapshot_date,
|
||||
COALESCE(SUM(units_received), 0)::bigint AS snap_received,
|
||||
COALESCE(SUM(units_sold), 0)::bigint AS snap_sold
|
||||
FROM public.daily_product_snapshots
|
||||
WHERE snapshot_date >= _backfill_start
|
||||
AND snapshot_date < CURRENT_DATE - _recent_recheck_days
|
||||
GROUP BY snapshot_date
|
||||
) snap_agg
|
||||
LEFT JOIN (
|
||||
SELECT received_date::date AS d, SUM(qty_each)::bigint AS actual_received
|
||||
FROM public.receivings
|
||||
WHERE received_date::date >= _backfill_start
|
||||
AND received_date::date < CURRENT_DATE - _recent_recheck_days
|
||||
GROUP BY received_date::date
|
||||
) recv_agg ON snap_agg.snapshot_date = recv_agg.d
|
||||
LEFT JOIN (
|
||||
SELECT date::date AS d,
|
||||
SUM(CASE WHEN quantity > 0 AND COALESCE(status, 'pending') NOT IN ('canceled', 'returned')
|
||||
THEN quantity ELSE 0 END)::bigint AS actual_sold
|
||||
FROM public.orders
|
||||
WHERE date::date >= _backfill_start
|
||||
AND date::date < CURRENT_DATE - _recent_recheck_days
|
||||
GROUP BY date::date
|
||||
) orders_agg ON snap_agg.snapshot_date = orders_agg.d
|
||||
WHERE snap_agg.snap_received != COALESCE(recv_agg.actual_received, 0)
|
||||
OR snap_agg.snap_sold != COALESCE(orders_agg.actual_sold, 0)
|
||||
UNION
|
||||
-- Recent days: always reprocess
|
||||
SELECT d::date
|
||||
FROM generate_series(
|
||||
(CURRENT_DATE - _recent_recheck_days)::timestamp,
|
||||
CURRENT_DATE::timestamp,
|
||||
'1 day'::interval
|
||||
) d
|
||||
) dates_to_process
|
||||
ORDER BY d
|
||||
LOOP
|
||||
_days_processed := _days_processed + 1;
|
||||
|
||||
-- Process only the days that need updating
|
||||
FOREACH _day_counter IN ARRAY _missing_days LOOP
|
||||
_target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day');
|
||||
RAISE NOTICE 'Processing date: %', _target_date;
|
||||
-- Classify why this date is being processed (for logging)
|
||||
IF _target_date >= CURRENT_DATE - _recent_recheck_days THEN
|
||||
RAISE NOTICE 'Processing date: % [recent recheck]', _target_date;
|
||||
ELSIF NOT EXISTS (SELECT 1 FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) THEN
|
||||
RAISE NOTICE 'Processing date: % [gap fill — no existing snapshot]', _target_date;
|
||||
ELSE
|
||||
RAISE NOTICE 'Processing date: % [stale data — snapshot aggregates mismatch source]', _target_date;
|
||||
END IF;
|
||||
|
||||
-- IMPORTANT: First delete any existing data for this date to prevent duplication
|
||||
DELETE FROM public.daily_product_snapshots
|
||||
@@ -90,7 +124,6 @@ BEGIN
|
||||
COALESCE(
|
||||
o.costeach, -- First use order-specific cost if available
|
||||
get_weighted_avg_cost(p.pid, o.date::date), -- Then use weighted average cost
|
||||
p.landing_cost_price, -- Fallback to landing cost
|
||||
p.cost_price -- Final fallback to current cost
|
||||
) * o.quantity
|
||||
ELSE 0 END), 0.00) AS cogs,
|
||||
@@ -124,14 +157,16 @@ BEGIN
|
||||
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
|
||||
),
|
||||
CurrentStock AS (
|
||||
-- Select current stock values directly from products table
|
||||
-- Use historical stock from stock_snapshots when available,
|
||||
-- falling back to current stock from products table
|
||||
SELECT
|
||||
pid,
|
||||
stock_quantity,
|
||||
COALESCE(landing_cost_price, cost_price, 0.00) as effective_cost_price,
|
||||
COALESCE(price, 0.00) as current_price,
|
||||
COALESCE(regular_price, 0.00) as current_regular_price
|
||||
FROM public.products
|
||||
p.pid,
|
||||
COALESCE(ss.stock_quantity, p.stock_quantity) AS stock_quantity,
|
||||
COALESCE(ss.stock_value, p.stock_quantity * COALESCE(p.cost_price, 0.00)) AS stock_value,
|
||||
COALESCE(p.price, 0.00) AS current_price,
|
||||
COALESCE(p.regular_price, 0.00) AS current_regular_price
|
||||
FROM public.products p
|
||||
LEFT JOIN stock_snapshots ss ON p.pid = ss.pid AND ss.snapshot_date = _target_date
|
||||
),
|
||||
ProductsWithActivity AS (
|
||||
-- Quick pre-filter to only process products with activity
|
||||
@@ -171,7 +206,7 @@ BEGIN
|
||||
COALESCE(sd.sku, p.sku) AS sku, -- Get SKU from sales data or products table
|
||||
-- Inventory Metrics (Using CurrentStock)
|
||||
cs.stock_quantity AS eod_stock_quantity,
|
||||
cs.stock_quantity * cs.effective_cost_price AS eod_stock_cost,
|
||||
cs.stock_value AS eod_stock_cost,
|
||||
cs.stock_quantity * cs.current_price AS eod_stock_retail,
|
||||
cs.stock_quantity * cs.current_regular_price AS eod_stock_gross,
|
||||
(cs.stock_quantity <= 0) AS stockout_flag,
|
||||
@@ -181,10 +216,10 @@ BEGIN
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00),
|
||||
COALESCE(sd.discounts, 0.00),
|
||||
COALESCE(sd.returns_revenue, 0.00),
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue,
|
||||
COALESCE(sd.cogs, 0.00),
|
||||
COALESCE(sd.gross_regular_revenue, 0.00),
|
||||
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit, -- Basic profit: Net Revenue - COGS
|
||||
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
|
||||
-- Receiving Metrics (From ReceivingData)
|
||||
COALESCE(rd.units_received, 0),
|
||||
COALESCE(rd.cost_received, 0.00),
|
||||
@@ -201,12 +236,18 @@ BEGIN
|
||||
RAISE NOTICE 'Created % daily snapshot records for % with sales/receiving activity', _total_records, _target_date;
|
||||
END LOOP;
|
||||
|
||||
-- Update the status table with the timestamp from the START of this run
|
||||
UPDATE public.calculate_status
|
||||
SET last_calculation_timestamp = _start_time
|
||||
WHERE module_name = _module_name;
|
||||
IF _days_processed = 0 THEN
|
||||
RAISE NOTICE 'No days need updating — all snapshot data is current.';
|
||||
ELSE
|
||||
RAISE NOTICE 'Processed % days total.', _days_processed;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Finished % processing for multiple dates. Duration: %', _module_name, clock_timestamp() - _start_time;
|
||||
-- Update the status table with the timestamp from the START of this run
|
||||
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
|
||||
VALUES (_module_name, _start_time)
|
||||
ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = _start_time;
|
||||
|
||||
RAISE NOTICE 'Finished % script. Duration: %', _module_name, clock_timestamp() - _start_time;
|
||||
|
||||
END $$;
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
-- Description: Populates lifecycle forecast columns on product_metrics from product_forecasts.
|
||||
-- Runs AFTER update_product_metrics.sql so that lead time / days of stock settings are available.
|
||||
-- Dependencies: product_metrics (fully populated), product_forecasts, settings tables.
|
||||
-- Frequency: After each metrics run and/or after forecast engine runs.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
_module_name TEXT := 'lifecycle_forecasts';
|
||||
_start_time TIMESTAMPTZ := clock_timestamp();
|
||||
_updated INT;
|
||||
BEGIN
|
||||
RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time;
|
||||
|
||||
-- Step 1: Set lifecycle_phase from product_forecasts (one phase per product)
|
||||
UPDATE product_metrics pm
|
||||
SET lifecycle_phase = sub.lifecycle_phase
|
||||
FROM (
|
||||
SELECT DISTINCT ON (pid) pid, lifecycle_phase
|
||||
FROM product_forecasts
|
||||
ORDER BY pid, forecast_date
|
||||
) sub
|
||||
WHERE pm.pid = sub.pid
|
||||
AND (pm.lifecycle_phase IS DISTINCT FROM sub.lifecycle_phase);
|
||||
|
||||
GET DIAGNOSTICS _updated = ROW_COUNT;
|
||||
RAISE NOTICE 'Updated lifecycle_phase for % products', _updated;
|
||||
|
||||
-- Step 2: Compute lifecycle-based lead time and planning period forecasts
|
||||
-- Uses each product's configured lead time and days of stock
|
||||
WITH forecast_sums AS (
|
||||
SELECT
|
||||
pf.pid,
|
||||
SUM(pf.forecast_units) FILTER (
|
||||
WHERE pf.forecast_date <= CURRENT_DATE + s.effective_lead_time
|
||||
) AS lt_forecast,
|
||||
SUM(pf.forecast_units) FILTER (
|
||||
WHERE pf.forecast_date <= CURRENT_DATE + s.effective_lead_time + s.effective_days_of_stock
|
||||
) AS pp_forecast
|
||||
FROM product_forecasts pf
|
||||
JOIN (
|
||||
SELECT
|
||||
p.pid,
|
||||
COALESCE(sp.lead_time_days, sv.default_lead_time_days,
|
||||
(SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_lead_time_days'), 14
|
||||
) AS effective_lead_time,
|
||||
COALESCE(sp.days_of_stock, sv.default_days_of_stock,
|
||||
(SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_days_of_stock'), 30
|
||||
) AS effective_days_of_stock
|
||||
FROM products p
|
||||
LEFT JOIN settings_product sp ON p.pid = sp.pid
|
||||
LEFT JOIN settings_vendor sv ON p.vendor = sv.vendor
|
||||
) s ON s.pid = pf.pid
|
||||
WHERE pf.forecast_date >= CURRENT_DATE
|
||||
GROUP BY pf.pid
|
||||
)
|
||||
UPDATE product_metrics pm
|
||||
SET
|
||||
lifecycle_lead_time_forecast = COALESCE(fs.lt_forecast, 0),
|
||||
lifecycle_planning_period_forecast = COALESCE(fs.pp_forecast, 0)
|
||||
FROM forecast_sums fs
|
||||
WHERE pm.pid = fs.pid
|
||||
AND (pm.lifecycle_lead_time_forecast IS DISTINCT FROM COALESCE(fs.lt_forecast, 0)
|
||||
OR pm.lifecycle_planning_period_forecast IS DISTINCT FROM COALESCE(fs.pp_forecast, 0));
|
||||
|
||||
GET DIAGNOSTICS _updated = ROW_COUNT;
|
||||
RAISE NOTICE 'Updated lifecycle forecasts for % products', _updated;
|
||||
|
||||
-- Step 3: Reclassify demand_pattern using residual CV (de-trended)
|
||||
-- For launch/decay products, raw CV is high because of expected lifecycle decay.
|
||||
-- We subtract the expected brand curve value to get residuals, then compute CV on those.
|
||||
-- Products that track their brand curve closely → low residual CV → "stable"
|
||||
-- Products with erratic deviations from curve → higher residual CV → "variable"/"sporadic"
|
||||
WITH product_curve AS (
|
||||
-- Get each product's brand curve and age
|
||||
SELECT
|
||||
pm.pid,
|
||||
pm.lifecycle_phase,
|
||||
pm.date_first_received,
|
||||
blc.amplitude,
|
||||
blc.decay_rate,
|
||||
blc.baseline
|
||||
FROM product_metrics pm
|
||||
JOIN products p ON p.pid = pm.pid
|
||||
LEFT JOIN brand_lifecycle_curves blc
|
||||
ON blc.brand = pm.brand
|
||||
AND blc.root_category IS NULL -- brand-only curve
|
||||
WHERE pm.lifecycle_phase IN ('launch', 'decay')
|
||||
AND pm.date_first_received IS NOT NULL
|
||||
AND blc.amplitude IS NOT NULL
|
||||
),
|
||||
daily_residuals AS (
|
||||
-- Compute residual = actual - expected for each snapshot day
|
||||
-- Curve params are in WEEKLY units; divide by 7 to get daily expected
|
||||
SELECT
|
||||
dps.pid,
|
||||
dps.units_sold,
|
||||
(pc.amplitude * EXP(-pc.decay_rate * (dps.snapshot_date - pc.date_first_received)::numeric / 7.0) + pc.baseline) / 7.0 AS expected,
|
||||
dps.units_sold - (pc.amplitude * EXP(-pc.decay_rate * (dps.snapshot_date - pc.date_first_received)::numeric / 7.0) + pc.baseline) / 7.0 AS residual
|
||||
FROM daily_product_snapshots dps
|
||||
JOIN product_curve pc ON pc.pid = dps.pid
|
||||
WHERE dps.snapshot_date >= CURRENT_DATE - INTERVAL '29 days'
|
||||
AND dps.snapshot_date <= CURRENT_DATE
|
||||
),
|
||||
residual_cv AS (
|
||||
SELECT
|
||||
pid,
|
||||
AVG(units_sold) AS avg_sales,
|
||||
CASE WHEN COUNT(*) >= 7 AND AVG(ABS(expected)) > 0.01 THEN
|
||||
STDDEV_POP(residual) / GREATEST(AVG(ABS(expected)), 0.1)
|
||||
END AS res_cv
|
||||
FROM daily_residuals
|
||||
GROUP BY pid
|
||||
)
|
||||
UPDATE product_metrics pm
|
||||
SET demand_pattern = classify_demand_pattern(rc.avg_sales, rc.res_cv)
|
||||
FROM residual_cv rc
|
||||
WHERE pm.pid = rc.pid
|
||||
AND rc.res_cv IS NOT NULL
|
||||
AND pm.demand_pattern IS DISTINCT FROM classify_demand_pattern(rc.avg_sales, rc.res_cv);
|
||||
|
||||
GET DIAGNOSTICS _updated = ROW_COUNT;
|
||||
RAISE NOTICE 'Reclassified demand_pattern for % launch/decay products', _updated;
|
||||
|
||||
-- Update tracking
|
||||
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
|
||||
VALUES (_module_name, clock_timestamp())
|
||||
ON CONFLICT (module_name) DO UPDATE SET
|
||||
last_calculation_timestamp = EXCLUDED.last_calculation_timestamp;
|
||||
|
||||
RAISE NOTICE '% module complete. Duration: %', _module_name, clock_timestamp() - _start_time;
|
||||
END $$;
|
||||
@@ -21,20 +21,30 @@ BEGIN
|
||||
RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time;
|
||||
|
||||
-- 1. Calculate Average Lead Time
|
||||
-- For each completed PO, find the earliest receiving from the same supplier
|
||||
-- within 180 days, then average those per-PO lead times per product.
|
||||
RAISE NOTICE 'Calculating Average Lead Time...';
|
||||
WITH LeadTimes AS (
|
||||
WITH po_first_receiving AS (
|
||||
SELECT
|
||||
po.pid,
|
||||
-- Calculate lead time by looking at when items ordered on POs were received
|
||||
AVG(GREATEST(1, (r.received_date::date - po.date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days
|
||||
po.po_id,
|
||||
po.date::date AS po_date,
|
||||
MIN(r.received_date::date) AS first_receive_date
|
||||
FROM public.purchase_orders po
|
||||
-- Join to receivings table to find actual receipts
|
||||
JOIN public.receivings r ON r.pid = po.pid
|
||||
WHERE po.status = 'done' -- Only include completed POs
|
||||
AND r.received_date >= po.date -- Ensure received date is not before order date
|
||||
-- Optional: add check to make sure receiving is related to PO if you have source_po_id
|
||||
-- AND (r.source_po_id = po.po_id OR r.source_po_id IS NULL)
|
||||
GROUP BY po.pid
|
||||
JOIN public.receivings r
|
||||
ON r.pid = po.pid
|
||||
AND r.supplier_id = po.supplier_id -- same supplier
|
||||
AND r.received_date >= po.date -- received after order
|
||||
AND r.received_date <= po.date + INTERVAL '180 days' -- within reasonable window
|
||||
WHERE po.status = 'done'
|
||||
GROUP BY po.pid, po.po_id, po.date
|
||||
),
|
||||
LeadTimes AS (
|
||||
SELECT
|
||||
pid,
|
||||
ROUND(AVG(GREATEST(1, first_receive_date - po_date))) AS avg_days
|
||||
FROM po_first_receiving
|
||||
GROUP BY pid
|
||||
)
|
||||
UPDATE public.product_metrics pm
|
||||
SET avg_lead_time_days = lt.avg_days::int
|
||||
|
||||
@@ -52,7 +52,7 @@ BEGIN
|
||||
COALESCE(p.price, 0.00) as current_price,
|
||||
COALESCE(p.regular_price, 0.00) as current_regular_price,
|
||||
COALESCE(p.cost_price, 0.00) as current_cost_price,
|
||||
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as current_effective_cost, -- Use landing if available, else cost
|
||||
COALESCE(p.cost_price, 0.00) as current_effective_cost,
|
||||
p.stock_quantity as current_stock,
|
||||
p.created_at,
|
||||
p.first_received,
|
||||
@@ -61,16 +61,72 @@ BEGIN
|
||||
p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each)
|
||||
FROM public.products p
|
||||
),
|
||||
-- Stale POs: open, >90 days past expected, AND a newer PO exists for the same product.
|
||||
-- These are likely abandoned/superseded and should not consume receivings in FIFO.
|
||||
StalePOLines AS (
|
||||
SELECT po.po_id, po.pid
|
||||
FROM public.purchase_orders po
|
||||
WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent',
|
||||
'electronically_ready_send', 'receiving_started')
|
||||
AND po.expected_date IS NOT NULL
|
||||
AND po.expected_date < _current_date - INTERVAL '90 days'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.purchase_orders newer
|
||||
WHERE newer.pid = po.pid
|
||||
AND newer.status NOT IN ('canceled', 'done')
|
||||
AND COALESCE(newer.date_ordered, newer.date_created)
|
||||
> COALESCE(po.date_ordered, po.date_created)
|
||||
)
|
||||
),
|
||||
-- All non-canceled, non-stale POs in FIFO order per (pid, supplier).
|
||||
-- Includes closed ('done') POs so they consume receivings before open POs.
|
||||
POFifo AS (
|
||||
SELECT
|
||||
po.pid, po.supplier_id, po.po_id, po.ordered, po.status,
|
||||
po.po_cost_price, po.expected_date,
|
||||
SUM(po.ordered) OVER (
|
||||
PARTITION BY po.pid, po.supplier_id
|
||||
ORDER BY COALESCE(po.date_ordered, po.date_created), po.po_id
|
||||
) - po.ordered AS cumulative_before
|
||||
FROM public.purchase_orders po
|
||||
WHERE po.status != 'canceled'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM StalePOLines s
|
||||
WHERE s.po_id = po.po_id AND s.pid = po.pid
|
||||
)
|
||||
),
|
||||
-- Total received per (product, supplier) across all receivings.
|
||||
SupplierReceived AS (
|
||||
SELECT pid, supplier_id, SUM(qty_each) AS total_received
|
||||
FROM public.receivings
|
||||
WHERE status IN ('partial_received', 'full_received', 'paid')
|
||||
GROUP BY pid, supplier_id
|
||||
),
|
||||
-- FIFO allocation: receivings fill oldest POs first per (pid, supplier).
|
||||
-- Only open PO lines are reported; closed POs just absorb receivings.
|
||||
OnOrderInfo AS (
|
||||
SELECT
|
||||
pid,
|
||||
SUM(ordered) AS on_order_qty,
|
||||
SUM(ordered * po_cost_price) AS on_order_cost,
|
||||
MIN(expected_date) AS earliest_expected_date
|
||||
FROM public.purchase_orders
|
||||
WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started')
|
||||
AND status NOT IN ('canceled', 'done')
|
||||
GROUP BY pid
|
||||
po.pid,
|
||||
SUM(GREATEST(0,
|
||||
po.ordered - GREATEST(0, LEAST(po.ordered,
|
||||
COALESCE(sr.total_received, 0) - po.cumulative_before
|
||||
))
|
||||
)) AS on_order_qty,
|
||||
SUM(GREATEST(0,
|
||||
po.ordered - GREATEST(0, LEAST(po.ordered,
|
||||
COALESCE(sr.total_received, 0) - po.cumulative_before
|
||||
))
|
||||
) * po.po_cost_price) AS on_order_cost,
|
||||
MIN(po.expected_date) FILTER (WHERE
|
||||
po.ordered > GREATEST(0, LEAST(po.ordered,
|
||||
COALESCE(sr.total_received, 0) - po.cumulative_before
|
||||
))
|
||||
) AS earliest_expected_date
|
||||
FROM POFifo po
|
||||
LEFT JOIN SupplierReceived sr ON sr.pid = po.pid AND sr.supplier_id = po.supplier_id
|
||||
WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent',
|
||||
'electronically_ready_send', 'receiving_started')
|
||||
GROUP BY po.pid
|
||||
),
|
||||
HistoricalDates AS (
|
||||
-- Note: Calculating these MIN/MAX values hourly can be slow on large tables.
|
||||
@@ -142,6 +198,17 @@ BEGIN
|
||||
FROM public.daily_product_snapshots
|
||||
GROUP BY pid
|
||||
),
|
||||
BeginningStock AS (
|
||||
-- Get stock level from 30 days ago for sell-through calculation.
|
||||
-- Uses the closest available snapshot if exact date is missing (activity-only snapshots).
|
||||
SELECT DISTINCT ON (pid)
|
||||
pid,
|
||||
eod_stock_quantity AS beginning_stock_30d
|
||||
FROM public.daily_product_snapshots
|
||||
WHERE snapshot_date <= _current_date - INTERVAL '30 days'
|
||||
AND snapshot_date >= _current_date - INTERVAL '37 days'
|
||||
ORDER BY pid, snapshot_date DESC
|
||||
),
|
||||
FirstPeriodMetrics AS (
|
||||
SELECT
|
||||
pid,
|
||||
@@ -204,17 +271,30 @@ BEGIN
|
||||
GROUP BY pid
|
||||
),
|
||||
DemandVariability AS (
|
||||
-- Calculate variance and standard deviation of daily sales
|
||||
-- Calculate variance and standard deviation of daily sales over the full 30-day window
|
||||
-- including zero-sales days (not just activity days) for accurate variability metrics.
|
||||
-- Uses algebraic equivalents to avoid expensive CROSS JOIN with generate_series.
|
||||
-- For N=30 total days, k active days, sum S, sum_sq SS:
|
||||
-- mean = S/N, variance = (SS/N) - (S/N)^2 (population variance over all N days)
|
||||
SELECT
|
||||
pid,
|
||||
COUNT(*) AS days_with_data,
|
||||
AVG(units_sold) AS avg_daily_sales,
|
||||
VARIANCE(units_sold) AS sales_variance,
|
||||
STDDEV(units_sold) AS sales_std_dev,
|
||||
-- Coefficient of variation
|
||||
CASE
|
||||
WHEN AVG(units_sold) > 0 THEN STDDEV(units_sold) / AVG(units_sold)
|
||||
ELSE NULL
|
||||
SUM(units_sold)::numeric / 30.0 AS avg_daily_sales,
|
||||
CASE WHEN SUM(units_sold) > 0 THEN
|
||||
(SUM(units_sold::numeric * units_sold::numeric) / 30.0)
|
||||
- (SUM(units_sold)::numeric / 30.0) * (SUM(units_sold)::numeric / 30.0)
|
||||
END AS sales_variance,
|
||||
CASE WHEN SUM(units_sold) > 0 THEN
|
||||
(|/ GREATEST(0,
|
||||
(SUM(units_sold::numeric * units_sold::numeric) / 30.0)
|
||||
- (SUM(units_sold)::numeric / 30.0) * (SUM(units_sold)::numeric / 30.0)
|
||||
))::numeric
|
||||
END AS sales_std_dev,
|
||||
CASE WHEN SUM(units_sold) > 0 THEN
|
||||
((|/ GREATEST(0,
|
||||
(SUM(units_sold::numeric * units_sold::numeric) / 30.0)
|
||||
- (SUM(units_sold)::numeric / 30.0) * (SUM(units_sold)::numeric / 30.0)
|
||||
)) / (SUM(units_sold)::numeric / 30.0))::numeric
|
||||
END AS sales_cv
|
||||
FROM public.daily_product_snapshots
|
||||
WHERE snapshot_date >= _current_date - INTERVAL '29 days'
|
||||
@@ -242,14 +322,51 @@ BEGIN
|
||||
GROUP BY pid
|
||||
),
|
||||
SeasonalityAnalysis AS (
|
||||
-- Simple seasonality detection
|
||||
-- Set-based seasonality detection (replaces per-product function calls)
|
||||
-- Computes monthly CV and peak-to-average ratio across the last 12 months
|
||||
SELECT
|
||||
p.pid,
|
||||
sp.seasonal_pattern,
|
||||
sp.seasonality_index,
|
||||
sp.peak_season
|
||||
FROM products p
|
||||
CROSS JOIN LATERAL detect_seasonal_pattern(p.pid) sp
|
||||
pid,
|
||||
CASE
|
||||
WHEN monthly_cv > 0.5 AND seasonality_index > 150 THEN 'strong'
|
||||
WHEN monthly_cv > 0.3 AND seasonality_index > 120 THEN 'moderate'
|
||||
ELSE 'none'
|
||||
END::varchar AS seasonal_pattern,
|
||||
CASE
|
||||
WHEN monthly_cv > 0.3 AND seasonality_index > 120 THEN seasonality_index
|
||||
ELSE 100::numeric
|
||||
END AS seasonality_index,
|
||||
CASE
|
||||
WHEN monthly_cv > 0.3 AND seasonality_index > 120
|
||||
THEN TRIM(TO_CHAR(TO_DATE(peak_month::text, 'MM'), 'Month'))
|
||||
ELSE NULL
|
||||
END::varchar AS peak_season
|
||||
FROM (
|
||||
SELECT
|
||||
pid,
|
||||
CASE WHEN overall_avg > 0 AND monthly_stddev IS NOT NULL
|
||||
THEN monthly_stddev / overall_avg ELSE 0 END AS monthly_cv,
|
||||
CASE WHEN overall_avg > 0
|
||||
THEN ROUND((max_month_avg / overall_avg * 100)::numeric, 2)
|
||||
ELSE 100 END AS seasonality_index,
|
||||
peak_month
|
||||
FROM (
|
||||
SELECT
|
||||
ms.pid,
|
||||
AVG(ms.month_avg) AS overall_avg,
|
||||
STDDEV(ms.month_avg) AS monthly_stddev,
|
||||
MAX(ms.month_avg) AS max_month_avg,
|
||||
(ARRAY_AGG(ms.mo ORDER BY ms.month_avg DESC))[1] AS peak_month
|
||||
FROM (
|
||||
SELECT pid, EXTRACT(MONTH FROM snapshot_date)::int AS mo, AVG(units_sold) AS month_avg
|
||||
FROM daily_product_snapshots
|
||||
WHERE snapshot_date >= CURRENT_DATE - INTERVAL '365 days'
|
||||
AND units_sold > 0
|
||||
GROUP BY pid, EXTRACT(MONTH FROM snapshot_date)
|
||||
) ms
|
||||
GROUP BY ms.pid
|
||||
HAVING COUNT(*) >= 3 -- Need at least 3 months for meaningful seasonality
|
||||
) agg
|
||||
) classified
|
||||
)
|
||||
-- Final UPSERT into product_metrics
|
||||
INSERT INTO public.product_metrics (
|
||||
@@ -257,7 +374,7 @@ BEGIN
|
||||
barcode, harmonized_tariff_code, vendor_reference, notions_reference, line, subline, artist,
|
||||
moq, rating, reviews, weight, length, width, height, country_of_origin, location,
|
||||
baskets, notifies, preorder_count, notions_inv_count,
|
||||
current_price, current_regular_price, current_cost_price, current_landing_cost_price,
|
||||
current_price, current_regular_price, current_cost_price,
|
||||
current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
|
||||
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
|
||||
date_created, date_first_received, date_last_received, date_first_sold, date_last_sold, age_days,
|
||||
@@ -295,7 +412,7 @@ BEGIN
|
||||
ci.barcode, ci.harmonized_tariff_code, ci.vendor_reference, ci.notions_reference, ci.line, ci.subline, ci.artist,
|
||||
ci.moq, ci.rating, ci.reviews, ci.weight, ci.length, ci.width, ci.height, ci.country_of_origin, ci.location,
|
||||
ci.baskets, ci.notifies, ci.preorder_count, ci.notions_inv_count,
|
||||
ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost,
|
||||
ci.current_price, ci.current_regular_price, ci.current_cost_price,
|
||||
ci.current_stock, ci.current_stock * ci.current_effective_cost, ci.current_stock * ci.current_price, ci.current_stock * ci.current_regular_price,
|
||||
COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00), COALESCE(ooi.on_order_qty, 0) * ci.current_price, ooi.earliest_expected_date,
|
||||
ci.created_at::date, COALESCE(ci.first_received::date, hd.date_first_received_calc), hd.date_last_received_calc, hd.date_first_sold, COALESCE(ci.date_last_sold, hd.max_order_date),
|
||||
@@ -321,9 +438,9 @@ BEGIN
|
||||
(GREATEST(0, ci.historical_total_sold - COALESCE(lr.lifetime_units_from_orders, 0)) *
|
||||
COALESCE(
|
||||
-- Use oldest known price from snapshots as proxy
|
||||
(SELECT revenue_7d / NULLIF(sales_7d, 0)
|
||||
(SELECT net_revenue / NULLIF(units_sold, 0)
|
||||
FROM daily_product_snapshots
|
||||
WHERE pid = ci.pid AND sales_7d > 0
|
||||
WHERE pid = ci.pid AND units_sold > 0
|
||||
ORDER BY snapshot_date ASC
|
||||
LIMIT 1),
|
||||
ci.current_price
|
||||
@@ -353,10 +470,10 @@ BEGIN
|
||||
(sa.stockout_days_30d / 30.0) * 100 AS stockout_rate_30d,
|
||||
sa.gross_regular_revenue_30d - sa.gross_revenue_30d AS markdown_30d,
|
||||
((sa.gross_regular_revenue_30d - sa.gross_revenue_30d) / NULLIF(sa.gross_regular_revenue_30d, 0)) * 100 AS markdown_rate_30d,
|
||||
-- Fix sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received)
|
||||
-- Approximating beginning inventory as current stock + units sold - units received
|
||||
-- Sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received)
|
||||
-- Uses actual snapshot from 30 days ago as beginning stock, falls back to avg_stock_units_30d
|
||||
(sa.sales_30d / NULLIF(
|
||||
ci.current_stock + sa.sales_30d + sa.returns_units_30d - sa.received_qty_30d,
|
||||
COALESCE(bs.beginning_stock_30d, sa.avg_stock_units_30d::int, 0) + sa.received_qty_30d,
|
||||
0
|
||||
)) * 100 AS sell_through_30d,
|
||||
|
||||
@@ -505,6 +622,7 @@ BEGIN
|
||||
LEFT JOIN PreviousPeriodMetrics ppm ON ci.pid = ppm.pid
|
||||
LEFT JOIN DemandVariability dv ON ci.pid = dv.pid
|
||||
LEFT JOIN ServiceLevels sl ON ci.pid = sl.pid
|
||||
LEFT JOIN BeginningStock bs ON ci.pid = bs.pid
|
||||
LEFT JOIN SeasonalityAnalysis season ON ci.pid = season.pid
|
||||
WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL -- Exclude products explicitly marked
|
||||
|
||||
@@ -514,7 +632,7 @@ BEGIN
|
||||
barcode = EXCLUDED.barcode, harmonized_tariff_code = EXCLUDED.harmonized_tariff_code, vendor_reference = EXCLUDED.vendor_reference, notions_reference = EXCLUDED.notions_reference, line = EXCLUDED.line, subline = EXCLUDED.subline, artist = EXCLUDED.artist,
|
||||
moq = EXCLUDED.moq, rating = EXCLUDED.rating, reviews = EXCLUDED.reviews, weight = EXCLUDED.weight, length = EXCLUDED.length, width = EXCLUDED.width, height = EXCLUDED.height, country_of_origin = EXCLUDED.country_of_origin, location = EXCLUDED.location,
|
||||
baskets = EXCLUDED.baskets, notifies = EXCLUDED.notifies, preorder_count = EXCLUDED.preorder_count, notions_inv_count = EXCLUDED.notions_inv_count,
|
||||
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price,
|
||||
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price,
|
||||
current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross,
|
||||
on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date,
|
||||
date_created = EXCLUDED.date_created, date_first_received = EXCLUDED.date_first_received, date_last_received = EXCLUDED.date_last_received, date_first_sold = EXCLUDED.date_first_sold, date_last_sold = EXCLUDED.date_last_sold, age_days = EXCLUDED.age_days,
|
||||
@@ -567,11 +685,26 @@ BEGIN
|
||||
product_metrics.replenishment_units IS DISTINCT FROM EXCLUDED.replenishment_units OR
|
||||
product_metrics.stock_cover_in_days IS DISTINCT FROM EXCLUDED.stock_cover_in_days OR
|
||||
product_metrics.yesterday_sales IS DISTINCT FROM EXCLUDED.yesterday_sales OR
|
||||
-- Check a few other important fields that might change
|
||||
product_metrics.date_last_sold IS DISTINCT FROM EXCLUDED.date_last_sold OR
|
||||
product_metrics.earliest_expected_date IS DISTINCT FROM EXCLUDED.earliest_expected_date OR
|
||||
product_metrics.lifetime_sales IS DISTINCT FROM EXCLUDED.lifetime_sales OR
|
||||
product_metrics.lifetime_revenue_quality IS DISTINCT FROM EXCLUDED.lifetime_revenue_quality
|
||||
product_metrics.lifetime_revenue_quality IS DISTINCT FROM EXCLUDED.lifetime_revenue_quality OR
|
||||
-- Derived metrics that can change even when source fields don't
|
||||
product_metrics.profit_30d IS DISTINCT FROM EXCLUDED.profit_30d OR
|
||||
product_metrics.cogs_30d IS DISTINCT FROM EXCLUDED.cogs_30d OR
|
||||
product_metrics.margin_30d IS DISTINCT FROM EXCLUDED.margin_30d OR
|
||||
product_metrics.stockout_days_30d IS DISTINCT FROM EXCLUDED.stockout_days_30d OR
|
||||
product_metrics.sell_through_30d IS DISTINCT FROM EXCLUDED.sell_through_30d OR
|
||||
-- Growth and variability metrics
|
||||
product_metrics.sales_growth_30d_vs_prev IS DISTINCT FROM EXCLUDED.sales_growth_30d_vs_prev OR
|
||||
product_metrics.revenue_growth_30d_vs_prev IS DISTINCT FROM EXCLUDED.revenue_growth_30d_vs_prev OR
|
||||
product_metrics.demand_pattern IS DISTINCT FROM EXCLUDED.demand_pattern OR
|
||||
product_metrics.seasonal_pattern IS DISTINCT FROM EXCLUDED.seasonal_pattern OR
|
||||
product_metrics.seasonality_index IS DISTINCT FROM EXCLUDED.seasonality_index OR
|
||||
product_metrics.service_level_30d IS DISTINCT FROM EXCLUDED.service_level_30d OR
|
||||
product_metrics.fill_rate_30d IS DISTINCT FROM EXCLUDED.fill_rate_30d OR
|
||||
-- Time-based safety net: always update if more than 1 day stale
|
||||
product_metrics.last_calculated < NOW() - INTERVAL '1 day'
|
||||
;
|
||||
|
||||
-- Update the status table with the timestamp from the START of this run
|
||||
|
||||
@@ -51,6 +51,10 @@ async function ensureInitialized() {
|
||||
...result.stats,
|
||||
groqEnabled: result.groqEnabled
|
||||
});
|
||||
|
||||
// Watch for taxonomy changes in the background (checks every hour)
|
||||
aiService.startBackgroundCheck(getDbConnection);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[AI Routes] Failed to initialize AI service:', error);
|
||||
@@ -431,4 +435,16 @@ router.post('/validate/sanity-check', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Kick off AI initialization in the background (no-op if already initialized).
|
||||
* Call once from server startup so the taxonomy embeddings are ready before
|
||||
* the first user request hits a taxonomy dropdown.
|
||||
*/
|
||||
function initInBackground() {
|
||||
ensureInitialized().catch(err =>
|
||||
console.error('[AI Routes] Background initialization failed:', err)
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.initInBackground = initInBackground;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1058,7 +1058,16 @@ router.get('/search-products', async (req, res) => {
|
||||
// Build WHERE clause with additional filters
|
||||
let whereClause;
|
||||
if (pid) {
|
||||
whereClause = `\n WHERE p.pid = ${connection.escape(Number(pid))}`;
|
||||
const pids = String(pid).split(',').map(Number).filter(n => !isNaN(n) && n > 0);
|
||||
if (pids.length === 0) {
|
||||
connection.release();
|
||||
return res.status(400).json({ error: 'Invalid pid parameter' });
|
||||
}
|
||||
if (pids.length === 1) {
|
||||
whereClause = `\n WHERE p.pid = ${connection.escape(pids[0])}`;
|
||||
} else {
|
||||
whereClause = `\n WHERE p.pid IN (${pids.map(p => connection.escape(p)).join(',')})`;
|
||||
}
|
||||
} else {
|
||||
whereClause = `
|
||||
WHERE (
|
||||
@@ -1142,12 +1151,13 @@ router.get('/search-products', async (req, res) => {
|
||||
p.itemnumber AS sku,
|
||||
p.upc AS barcode,
|
||||
p.harmonized_tariff_code,
|
||||
pcp.price_each AS price,
|
||||
MIN(pcp.price_each) AS price,
|
||||
p.sellingprice AS regular_price,
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0)
|
||||
THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0)
|
||||
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
|
||||
WHEN sid.supplier_id = 92 THEN
|
||||
CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END
|
||||
ELSE
|
||||
CASE WHEN COALESCE(sid.supplier_cost_each, 0) > 0 THEN sid.supplier_cost_each ELSE sid.notions_cost_each END
|
||||
END AS cost_price,
|
||||
s.companyname AS vendor,
|
||||
sid.supplier_itemnumber AS vendor_reference,
|
||||
@@ -1263,12 +1273,13 @@ const PRODUCT_SELECT = `
|
||||
p.itemnumber AS sku,
|
||||
p.upc AS barcode,
|
||||
p.harmonized_tariff_code,
|
||||
pcp.price_each AS price,
|
||||
MIN(pcp.price_each) AS price,
|
||||
p.sellingprice AS regular_price,
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0)
|
||||
THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0)
|
||||
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
|
||||
WHEN sid.supplier_id = 92 THEN
|
||||
CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END
|
||||
ELSE
|
||||
CASE WHEN COALESCE(sid.supplier_cost_each, 0) > 0 THEN sid.supplier_cost_each ELSE sid.notions_cost_each END
|
||||
END AS cost_price,
|
||||
s.companyname AS vendor,
|
||||
sid.supplier_itemnumber AS vendor_reference,
|
||||
@@ -1884,4 +1895,772 @@ router.get('/product-categories/:pid', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Build a PIDs-from-query SQL using product_query_filter rows (mirrors productquery.class.php logic)
|
||||
// NOTE: PRODUCT_SELECT uses aliases: products→p, current_inventory→ci, supplier_item_data→sid
|
||||
// All filter conditions must use those aliases. Extra JOINs (category index, etc.) are appended.
|
||||
//
|
||||
// Constants sourced from registry.class.php / product_category.class.php (ACOT store id confirmed by user):
|
||||
// ACOT_STORE=0, SRC_ACOT=10, SRC_PREORDER=11, SRC_NOTIONS=13
|
||||
// CAT types: section=10, cat=11, subcat=12, subsubcat=13, theme=20, subtheme=21, digitheme=30
|
||||
function buildQueryFilterSql(filters) {
|
||||
// filterGroups: Map<key, { conditions: string[], isNot: boolean, ororor: boolean }>
|
||||
// ororor=true means this group is OR-connected to the previous group (PHP filter_or mechanism)
|
||||
const filterGroups = new Map();
|
||||
const joinTables = new Map(); // alias -> JOIN clause
|
||||
const unsupported = [];
|
||||
|
||||
function addGroup(key, condition, isNot = false, ororor = false) {
|
||||
if (!filterGroups.has(key)) filterGroups.set(key, { conditions: [], isNot, ororor });
|
||||
filterGroups.get(key).conditions.push(condition);
|
||||
}
|
||||
function addJoin(alias, clause) {
|
||||
if (!joinTables.has(alias)) joinTables.set(alias, clause);
|
||||
}
|
||||
// INNER JOIN on product_category_index (product must have this category to appear)
|
||||
function ciJoin(alias) {
|
||||
addJoin(alias, `JOIN product_category_index AS ${alias} ON (p.pid=${alias}.pid)`);
|
||||
}
|
||||
|
||||
// Translate operator string to SQL operator
|
||||
function toSqlOp(op) {
|
||||
const map = {
|
||||
equals: '=', notequals: '<>', greater: '>', greater_equals: '>=',
|
||||
less: '<', less_equals: '<=', between: ' BETWEEN ',
|
||||
contains: ' LIKE ', notcontains: ' NOT LIKE ', begins: ' LIKE ',
|
||||
true: '<>0', true1: '=1', false: '=0', isnull: ' IS NULL',
|
||||
};
|
||||
return map[op] || '=';
|
||||
}
|
||||
|
||||
// Proper MySQL string escaping (mirrors mysql_real_escape_string order)
|
||||
function strVal(v) {
|
||||
return String(v)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\0/g, '\\0')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/\x1a/g, '\\Z');
|
||||
}
|
||||
|
||||
// URL-decode then escape — mirrors PHP: safefor_query(urldecode($row['filter_text1']))
|
||||
function decode(v) {
|
||||
if (!v) return '';
|
||||
try { return decodeURIComponent(String(v)); } catch { return String(v); }
|
||||
}
|
||||
|
||||
// Numeric field with BETWEEN support (appends AND t2 when operator is BETWEEN)
|
||||
function numFilt(field, sqlOp, t1, t2) {
|
||||
if (sqlOp.trim() === 'BETWEEN' && t2) return `${field} BETWEEN ${parseFloat(t1) || 0} AND ${parseFloat(t2) || 0}`;
|
||||
return `${field}${sqlOp}${parseFloat(t1) || 0}`;
|
||||
}
|
||||
|
||||
// Date/string field with BETWEEN support
|
||||
function dateFilt(field, sqlOp, t1, t2) {
|
||||
if (sqlOp.trim() === 'BETWEEN' && t2) return `${field} BETWEEN '${strVal(t1)}' AND '${strVal(t2)}'`;
|
||||
return `${field}${sqlOp}'${strVal(t1)}'`;
|
||||
}
|
||||
|
||||
// Store/source constants (registry.class.php; ACOT store id confirmed by user)
|
||||
const ACOT_STORE = 0;
|
||||
const SRC_ACOT = 10;
|
||||
const SRC_PREORDER = 11;
|
||||
const SRC_NOTIONS = 13;
|
||||
|
||||
// Category type constants (product_category.class.php)
|
||||
const CAT_THEMES = '20,21';
|
||||
const CAT_CATEGORIES_NO_SECTION = '11,12,13';
|
||||
|
||||
for (const row of filters) {
|
||||
const sqlOp = toSqlOp(row.filter_operator);
|
||||
const isNot = sqlOp === '<>' || row.filter_operator === 'notequals' || row.filter_operator === 'notcontains';
|
||||
const t1 = decode(row.filter_text1);
|
||||
const t2 = decode(row.filter_text2);
|
||||
const filterOr = Boolean(row.filter_or);
|
||||
|
||||
switch (row.filter_type) {
|
||||
|
||||
// ── products table (aliased as p) ──────────────────────────────────────
|
||||
case 'company':
|
||||
if (t1 && t2) {
|
||||
const filt = `(p.company=${parseFloat(t1)||0} AND p.line=${parseFloat(t2)||0})`;
|
||||
addGroup('company' + sqlOp, isNot ? `NOT ${filt}` : filt, isNot);
|
||||
} else {
|
||||
addGroup('company' + sqlOp, `p.company${sqlOp}${parseFloat(t1)||0}`, isNot);
|
||||
}
|
||||
break;
|
||||
case 'line': addGroup('line' + sqlOp, numFilt('p.line', sqlOp, t1, t2), isNot); break;
|
||||
case 'subline': addGroup('subline' + sqlOp, numFilt('p.subline', sqlOp, t1, t2), isNot); break;
|
||||
case 'no_company': addGroup('no_company', 'p.company=0'); break;
|
||||
case 'no_line': addGroup('no_line', 'p.line=0'); break;
|
||||
case 'no_subline': addGroup('no_subline', 'p.subline=0'); break;
|
||||
case 'artist': addGroup('artist' + sqlOp, numFilt('p.artist', sqlOp, t1, t2), isNot); break;
|
||||
case 'size_cat': addGroup('size_cat' + sqlOp, numFilt('p.size_cat', sqlOp, t1, t2), isNot); break;
|
||||
case 'dimension': addGroup('dimension' + sqlOp, numFilt('p.dimension', sqlOp, t1, t2), isNot); break;
|
||||
case 'yarn_weight': addGroup('yarn_weight' + sqlOp, numFilt('p.yarn_weight', sqlOp, t1, t2), isNot); break;
|
||||
case 'material': addGroup('material' + sqlOp, numFilt('p.material', sqlOp, t1, t2), isNot); break;
|
||||
case 'weight': addGroup('weight' + sqlOp, numFilt('p.weight', sqlOp, t1, t2)); break;
|
||||
case 'weight_price_ratio': addGroup('weight' + sqlOp, numFilt('p.weight/p.price_for_sort', sqlOp, t1, t2)); break;
|
||||
case 'price_weight_ratio': addGroup('weight' + sqlOp, numFilt('p.price_for_sort/p.weight', sqlOp, t1, t2)); break;
|
||||
case 'length': addGroup('length' + sqlOp, numFilt('p.length', sqlOp, t1, t2)); break;
|
||||
case 'width': addGroup('width' + sqlOp, numFilt('p.width', sqlOp, t1, t2)); break;
|
||||
case 'height': addGroup('height' + sqlOp, numFilt('p.height', sqlOp, t1, t2)); break;
|
||||
case 'no_dim': addGroup('no_dim', 'p.length=0 AND p.width=0 AND p.height=0'); break;
|
||||
case 'hide': addGroup('hide', `p.hide${sqlOp}`); break;
|
||||
case 'hide_in_shop':addGroup('hide_in_shop', `p.hide_in_shop${sqlOp}`); break;
|
||||
case 'discontinued':addGroup('discontinued', `p.discontinued${sqlOp}`); break;
|
||||
case 'force_flag': addGroup('force_flag', `p.force_flag${sqlOp}`); break;
|
||||
case 'exclusive': addGroup('exclusive', `p.exclusive${sqlOp}`); break;
|
||||
case 'lock_quantity': addGroup('lock_quantity', `p.lock_qty${sqlOp}`); break;
|
||||
case 'show_notify': addGroup('show_notify', `p.show_notify${sqlOp}`); break;
|
||||
case 'downloadable':addGroup('downloadable', `p.downloadable${sqlOp}`); break;
|
||||
case 'usa_only': addGroup('usa_only', `p.usa_only${sqlOp}`); break;
|
||||
case 'not_clearance': addGroup('not_clearance', `p.not_clearance${sqlOp}`); break;
|
||||
case 'stat_stop': addGroup('stat_stop', `p.stat_stop${sqlOp}`); break;
|
||||
case 'notnew': addGroup('notnew', `p.notnew${sqlOp}`); break;
|
||||
case 'not_backinstock': addGroup('not_backinstock', `p.not_backinstock${sqlOp}`); break;
|
||||
case 'reorder': addGroup('reorder', `p.reorder${sqlOp}${parseFloat(t1)||0}`); break;
|
||||
case 'score': addGroup('score' + sqlOp, numFilt('p.score', sqlOp, t1, t2)); break;
|
||||
case 'sold_view_score': addGroup('sold_view_score' + sqlOp, numFilt('p.sold_view_score', sqlOp, t1, t2)); break;
|
||||
case 'visibility_score': addGroup('visibility_score' + sqlOp, numFilt('p.visibility_score', sqlOp, t1, t2)); break;
|
||||
case 'health_score': addGroup('health_score' + sqlOp, `p.health_score${sqlOp}'${strVal(t1)}'`); break;
|
||||
case 'tax_code': addGroup('tax_code', `p.tax_code${sqlOp}${parseFloat(t1)||0}`); break;
|
||||
case 'investor': addGroup('investor' + sqlOp, `p.investorid${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||
case 'price':
|
||||
case 'default_price':
|
||||
addGroup('default_price' + sqlOp, numFilt('p.sellingprice', sqlOp, t1, t2), isNot); break;
|
||||
case 'price_for_sort':
|
||||
addGroup('price_for_sort' + sqlOp, numFilt('p.price_for_sort', sqlOp, t1, t2)); break;
|
||||
case 'salepercent_for_sort':
|
||||
case 'salepercent_for_sort__clearance': {
|
||||
// PHP divides by 100 if value > 1 (percentages stored as decimals)
|
||||
const v1 = (parseFloat(t1)||0) > 1 ? (parseFloat(t1)/100) : (parseFloat(t1)||0);
|
||||
const v2 = t2 ? ((parseFloat(t2)||0) > 1 ? (parseFloat(t2)/100) : (parseFloat(t2)||0)) : null;
|
||||
const filt = (sqlOp.trim() === 'BETWEEN' && v2 != null)
|
||||
? `p.salepercent_for_sort BETWEEN ${v1} AND ${v2}`
|
||||
: `p.salepercent_for_sort${sqlOp}${v1}`;
|
||||
addGroup(row.filter_type + sqlOp, filt); break;
|
||||
}
|
||||
case 'is_clearance':
|
||||
addGroup('is_clearance' + sqlOp, `(p.clearance_date != '0000-00-00 00:00:00')${sqlOp}`); break;
|
||||
case 'msrp': addGroup('msrp' + sqlOp, numFilt('p.msrp', sqlOp, t1, t2)); break;
|
||||
case 'default_less_msrp':addGroup('default_less_msrp', '(p.sellingprice < p.msrp)'); break;
|
||||
case 'default_more_msrp':addGroup('default_more_msrp', '(p.sellingprice > p.msrp)'); break;
|
||||
case 'minimum_advertised_price':
|
||||
addGroup('map', numFilt('p.minimum_advertised_price', sqlOp, t1, t2)); break;
|
||||
case 'minimum_advertised_price_error':
|
||||
addGroup('map_error', row.filter_operator !== 'false'
|
||||
? 'p.minimum_advertised_price > p.sellingprice'
|
||||
: 'p.minimum_advertised_price <= p.sellingprice');
|
||||
break;
|
||||
case 'wholesale_discount': {
|
||||
const v1 = (parseFloat(t1)||0) > 1 ? (parseFloat(t1)/100) : (parseFloat(t1)||0);
|
||||
const v2 = t2 ? ((parseFloat(t2)||0) > 1 ? (parseFloat(t2)/100) : (parseFloat(t2)||0)) : null;
|
||||
const filt = (sqlOp.trim() === 'BETWEEN' && v2 != null)
|
||||
? `p.wholesale_discount BETWEEN ${v1} AND ${v2}`
|
||||
: `p.wholesale_discount${sqlOp}${v1}`;
|
||||
addGroup('wholesale_discount' + sqlOp, filt); break;
|
||||
}
|
||||
case 'wholesale_unit_qty': addGroup('wholesale_unit_qty' + sqlOp, numFilt('p.wholesale_unit_qty', sqlOp, t1, t2)); break;
|
||||
case 'points_multiplier': addGroup('points_multiplier' + sqlOp, numFilt('p.points_multiplier', sqlOp, t1, t2)); break;
|
||||
case 'points_bonus': addGroup('points_bonus' + sqlOp, numFilt('p.points_bonus', sqlOp, t1, t2)); break;
|
||||
case 'points_extra': addGroup('points_extra', '(p.points_multiplier>.5 OR p.points_bonus>0)'); break;
|
||||
case 'auto_pricing_allowed': addGroup('auto_pricing', 'p.price_lock=0'); break;
|
||||
case 'auto_pricing_disallowed': addGroup('auto_pricing', 'p.price_lock=1'); break;
|
||||
case 'handling_fee': addGroup('handling_fee' + sqlOp, numFilt('p.handling_fee', sqlOp, t1, t2)); break;
|
||||
case 'ship_tier': addGroup('ship_tier' + sqlOp, numFilt('p.shipping_tier', sqlOp, t1, t2)); break;
|
||||
case 'shipping_restrictions': addGroup('ship_tier' + sqlOp, `p.shipping_restrictions${sqlOp}${parseFloat(t1)||0}`); break;
|
||||
case 'min_qty_wanted': addGroup('min_qty_wanted', numFilt('p.min_qty_wanted', sqlOp, t1, t2)); break;
|
||||
case 'qty_bundled': addGroup('min_qty_wanted', numFilt('p.qty_bundled', sqlOp, t1, t2)); break;
|
||||
case 'no_40_percent_promo': addGroup('no_40_percent_promo', `p.no_40_percent_promo${sqlOp}`); break;
|
||||
case 'exclude_google_feed': addGroup('exclude_google_feed', `p.exclude_google_feed${sqlOp}`); break;
|
||||
case 'store': {
|
||||
const bit = Math.pow(2, parseInt(t1) || 0);
|
||||
addGroup('store_' + (parseInt(t1)||0), `p.store & ${bit}${sqlOp}${bit}`); break;
|
||||
}
|
||||
case 'no_store': addGroup('store_' + (parseInt(t1)||0), 'p.store=0'); break;
|
||||
case 'location': {
|
||||
let filt = `p.aisle${sqlOp}'${strVal(t1)}'`;
|
||||
if (t2) filt += ` AND p.rack${sqlOp}'${strVal(t2)}'`;
|
||||
addGroup('location' + sqlOp, filt); break;
|
||||
}
|
||||
case 'name':
|
||||
addGroup('name' + sqlOp,
|
||||
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||
? `p.description${sqlOp}'%${strVal(t1)}%'`
|
||||
: `p.description${sqlOp}'${strVal(t1)}'`, isNot);
|
||||
break;
|
||||
case 'short_description':
|
||||
addGroup('short_description' + sqlOp,
|
||||
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||
? `p.description_short${sqlOp}'%${strVal(t1)}%'`
|
||||
: `p.description_short${sqlOp}'${strVal(t1)}'`, isNot);
|
||||
break;
|
||||
case 'description':
|
||||
addGroup('description' + sqlOp,
|
||||
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||
? `p.notes${sqlOp}'%${strVal(t1)}%'`
|
||||
: `p.notes${sqlOp}'${strVal(t1)}'`, isNot);
|
||||
break;
|
||||
case 'description_char_count':
|
||||
addGroup('description_char_count' + sqlOp, `CHAR_LENGTH(p.notes)${sqlOp}${parseFloat(t1)||0}`); break;
|
||||
case 'description2':
|
||||
addGroup('description2' + sqlOp,
|
||||
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||
? `p.notes2${sqlOp}'%${strVal(t1)}%'`
|
||||
: `p.notes2${sqlOp}'${strVal(t1)}'`, isNot);
|
||||
break;
|
||||
case 'keyword':
|
||||
addGroup('keyword' + sqlOp,
|
||||
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||
? `p.keyword1${sqlOp}'%${strVal(t1)}%'`
|
||||
: `p.keyword1${sqlOp}'${strVal(t1)}'`, isNot);
|
||||
break;
|
||||
case 'notes':
|
||||
addGroup('notes' + sqlOp,
|
||||
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||
? `p.priv_notes${sqlOp}'%${strVal(t1)}%'`
|
||||
: `p.priv_notes${sqlOp}'${strVal(t1)}'`, isNot);
|
||||
break;
|
||||
case 'price_notes':
|
||||
addGroup('price_notes' + sqlOp,
|
||||
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||
? `p.price_notes${sqlOp}'%${strVal(t1)}%'`
|
||||
: `p.price_notes${sqlOp}'${strVal(t1)}'`);
|
||||
break;
|
||||
case 'itemnumber':
|
||||
addGroup('itemnumber_' + sqlOp, `p.itemnumber${sqlOp}'${strVal(t1)}'`, isNot); break;
|
||||
case 'pid':
|
||||
case 'pid_auto': {
|
||||
// filter_or=true means OR-connect this group to the previous one (PHP OROROR mechanism)
|
||||
const key = 'pid' + sqlOp + (filterOr ? 'OROROR' : '');
|
||||
addGroup(key, `p.pid${sqlOp}${parseInt(t1) || 0}`, isNot, filterOr);
|
||||
break;
|
||||
}
|
||||
case 'upc':
|
||||
addGroup('upc' + sqlOp, `p.upc${sqlOp}'${strVal(t1)}'`, isNot); break;
|
||||
case 'size':
|
||||
addGroup('size' + sqlOp,
|
||||
row.filter_operator === 'begins' ? `p.size LIKE '${strVal(t1)}%'` : `p.size${sqlOp}'${strVal(t1)}'`, isNot);
|
||||
break;
|
||||
case 'country_of_origin':
|
||||
addGroup('country_of_origin',
|
||||
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||
? `p.country_of_origin${sqlOp}'%${strVal(t1)}%'`
|
||||
: `p.country_of_origin${sqlOp}'${strVal(t1)}'`);
|
||||
break;
|
||||
case 'date_in': addGroup('date_in' + sqlOp, dateFilt('DATE(p.datein)', sqlOp, t1, t2)); break;
|
||||
case 'date_in_days':
|
||||
case 'age': addGroup('date_in' + sqlOp, numFilt('DATEDIFF(NOW(),p.datein)', sqlOp, t1, t2)); break;
|
||||
case 'date_created': addGroup('date_created' + sqlOp, dateFilt('p.date_created', sqlOp, t1, t2)); break;
|
||||
case 'date_created_days': addGroup('date_created' + sqlOp, numFilt('DATEDIFF(NOW(),p.date_created)', sqlOp, t1, t2)); break;
|
||||
case 'date_modified': addGroup('date_modified' + sqlOp, dateFilt('p.stamp', sqlOp, t1, t2)); break;
|
||||
case 'date_modified_days': addGroup('date_modified' + sqlOp, numFilt('DATEDIFF(NOW(),p.stamp)', sqlOp, t1, t2)); break;
|
||||
case 'date_refill': addGroup('date_refill' + sqlOp, dateFilt('p.date_refill', sqlOp, t1, t2)); break;
|
||||
case 'date_refill_days': addGroup('date_refill' + sqlOp, numFilt('DATEDIFF(NOW(),p.date_refill)', sqlOp, t1, t2)); break;
|
||||
case 'new':
|
||||
addGroup('new', `DATEDIFF(NOW(),p.date_ol) <= ${parseInt(t1) || 45}`);
|
||||
addGroup('notnew', 'p.notnew=0');
|
||||
break;
|
||||
case 'new_in':
|
||||
addGroup('new2', `p.datein BETWEEN NOW()-INTERVAL ${parseInt(t1) || 30} DAY AND NOW()`);
|
||||
addGroup('notnew', 'p.notnew=0');
|
||||
break;
|
||||
case 'backinstock':
|
||||
addGroup('backinstock_1', `p.date_refill BETWEEN NOW()-INTERVAL ${parseInt(t1)||30} DAY AND NOW()`);
|
||||
addGroup('backinstock_2', 'p.date_refill > p.datein');
|
||||
addGroup('backinstock_3', 'NOT (p.datein BETWEEN NOW()-INTERVAL 30 DAY AND NOW())');
|
||||
break;
|
||||
case 'arrivals':
|
||||
addGroup('arrivals', `(p.date_ol BETWEEN NOW()-INTERVAL ${parseInt(t1)||30} DAY AND NOW() AND p.notnew=0)`);
|
||||
addGroup('arrivals', `(p.date_refill BETWEEN NOW()-INTERVAL ${parseInt(t1)||30} DAY AND NOW() AND p.date_refill > p.datein)`);
|
||||
break;
|
||||
|
||||
// ── current_inventory (aliased as ci, already LEFT JOINed in PRODUCT_SELECT) ──
|
||||
case 'count': addGroup('count' + sqlOp, numFilt('ci.available', sqlOp, t1, t2)); break;
|
||||
case 'count_onhand': addGroup('count_onhand' + sqlOp, numFilt('(ci.count-ci.pending)', sqlOp, t1, t2)); break;
|
||||
case 'count_shelf': addGroup('count_shelf' + sqlOp, numFilt('ci.count_shelf', sqlOp, t1, t2)); break;
|
||||
case 'on_order': addGroup('on_order' + sqlOp, numFilt('ci.onorder', sqlOp, t1, t2)); break;
|
||||
case 'on_preorder': addGroup('on_preorder' + sqlOp, numFilt('ci.onpreorder', sqlOp, t1, t2)); break;
|
||||
case 'infinite': addGroup('infinite', `ci.infinite${sqlOp}`); break;
|
||||
case 'pending': addGroup('pending', numFilt('ci.pending', sqlOp, t1, t2)); break;
|
||||
case 'date_sold': addGroup('date_sold' + sqlOp, numFilt('DATEDIFF(NOW(),ci.lastsolddate)', sqlOp, t1, t2)); break;
|
||||
case 'average_cost': addGroup('avg_cost' + sqlOp, numFilt('ci.avg_cost', sqlOp, t1, t2)); break;
|
||||
case 'markup': addGroup('markup' + sqlOp, numFilt('(p.sellingprice/ci.avg_cost*100-100)', sqlOp, t1, t2)); break;
|
||||
case 'total_sold': addGroup('total_sold' + sqlOp, numFilt('ci.totalsold', sqlOp, t1, t2)); break;
|
||||
case 'inbaskets': addGroup('inbaskets', numFilt('ci.baskets', sqlOp, t1, t2)); break;
|
||||
|
||||
// ── supplier_item_data (aliased as sid, already LEFT JOINed in PRODUCT_SELECT) ──
|
||||
case 'supplier':
|
||||
addGroup('supplier' + sqlOp, `sid.supplier_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||
case 'supplier_cost_each':
|
||||
addGroup('supplier_cost_each' + sqlOp, numFilt('sid.supplier_cost_each', sqlOp, t1, t2)); break;
|
||||
case 'notions_cost_each':
|
||||
addGroup('notions_cost_each' + sqlOp, numFilt('sid.notions_cost_each', sqlOp, t1, t2)); break;
|
||||
case 'supplier_qty_per_unit':
|
||||
addGroup('supplier_qty_per_unit' + sqlOp, numFilt('sid.supplier_qty_per_unit', sqlOp, t1, t2)); break;
|
||||
case 'notions_qty_per_unit':
|
||||
addGroup('notions_qty_per_unit' + sqlOp, numFilt('sid.notions_qty_per_unit', sqlOp, t1, t2)); break;
|
||||
case 'case_pack':
|
||||
addGroup('case_pack', `sid.notions_case_pack${sqlOp}${parseFloat(t1)||0}`); break;
|
||||
case 'notions_discontinued':
|
||||
addGroup('notions_discontinued' + sqlOp, `sid.notions_discontinued${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||
case 'missing_any_cost':
|
||||
addGroup('missing_any_cost', 'sid.supplier_cost_each=0 OR sid.notions_cost_each=0 OR sid.supplier_cost_each IS NULL OR sid.notions_cost_each IS NULL OR (SELECT COUNT(*) FROM product_inventory WHERE product_inventory.pid=p.pid)=0');
|
||||
break;
|
||||
case 'notions_itemnumber': {
|
||||
// Use a separate LEFT JOIN alias so IS NULL check works correctly
|
||||
addJoin('sid_l', 'LEFT JOIN supplier_item_data AS sid_l ON (p.pid=sid_l.pid)');
|
||||
let filt = `sid_l.notions_itemnumber${sqlOp}'${strVal(t1)}'`;
|
||||
if (sqlOp === '=' && t1 === '') filt += ' OR sid_l.notions_itemnumber IS NULL';
|
||||
addGroup('notions_itemnumber' + sqlOp, filt, isNot); break;
|
||||
}
|
||||
case 'supplier_itemnumber': {
|
||||
addJoin('sid_l', 'LEFT JOIN supplier_item_data AS sid_l ON (p.pid=sid_l.pid)');
|
||||
let filt = `sid_l.supplier_itemnumber${sqlOp}'${strVal(t1)}'`;
|
||||
if (sqlOp === '=' && t1 === '') filt += ' OR sid_l.supplier_itemnumber IS NULL';
|
||||
addGroup('supplier_itemnumber' + sqlOp, filt, isNot); break;
|
||||
}
|
||||
case 'supplier_cost_each_grouped':
|
||||
addGroup('supplier_cost_each' + sqlOp, numFilt('sid.supplier_cost_each', sqlOp, t1, t2)); break;
|
||||
|
||||
// ── category index: extra INNER JOINs needed ───────────────────────────
|
||||
case 'type':
|
||||
case 'cat':
|
||||
ciJoin('product_ci_cat');
|
||||
addGroup('cat' + sqlOp, `product_ci_cat.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||
case 'cat2':
|
||||
ciJoin('product_ci_cat2');
|
||||
addGroup('cat2' + sqlOp, `product_ci_cat2.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||
case 'subtype':
|
||||
case 'subcat':
|
||||
ciJoin('product_ci_subcat');
|
||||
addGroup('subcat' + sqlOp, `product_ci_subcat.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||
case 'subsubcat':
|
||||
ciJoin('product_ci_subsubcat');
|
||||
addGroup('subsubcat' + sqlOp, `product_ci_subsubcat.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||
case 'section':
|
||||
ciJoin('product_ci_section');
|
||||
addGroup('section' + sqlOp, `product_ci_section.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||
case 'theme':
|
||||
ciJoin('product_ci_theme');
|
||||
addGroup('themes' + sqlOp, `product_ci_theme.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||
case 'subtheme':
|
||||
ciJoin('product_ci_subtheme');
|
||||
addGroup('subtheme' + sqlOp, `product_ci_subtheme.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||
case 'digitheme':
|
||||
ciJoin('product_ci_digitheme');
|
||||
addGroup('digithemes' + sqlOp, `product_ci_digitheme.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||
case 'all_categories':
|
||||
if (sqlOp === '=') {
|
||||
ciJoin('product_ci_allcats');
|
||||
addGroup('allcategories=', `product_ci_allcats.cat_id=${parseFloat(t1)||0}`);
|
||||
} else {
|
||||
const alias = `not_ci_${parseInt(t1) || 0}`;
|
||||
addJoin(alias, `LEFT JOIN product_category_index AS ${alias} ON (${alias}.pid=p.pid AND ${alias}.cat_id=${parseFloat(t1)||0})`);
|
||||
addGroup('allcategories<>', `${alias}.cat_id IS NULL`);
|
||||
}
|
||||
break;
|
||||
case 'not_section':
|
||||
addGroup('not_section', `(SELECT 1 FROM product_category_index WHERE product_category_index.pid=p.pid AND cat_id=${parseInt(t1)||0}) IS NULL`);
|
||||
break;
|
||||
case 'all_themes':
|
||||
if (sqlOp === '=') {
|
||||
ciJoin('product_ci_allthemes');
|
||||
addGroup('allthemes=', `product_ci_allthemes.cat_id=${parseFloat(t1)||0}`);
|
||||
} else {
|
||||
addJoin('product_ci_not_allthemes', `LEFT JOIN product_category_index AS product_ci_not_allthemes ON (p.pid=product_ci_not_allthemes.pid AND product_ci_not_allthemes.cat_id=${parseFloat(t1)||0})`);
|
||||
addGroup('allthemes<>', 'product_ci_not_allthemes.pid IS NULL');
|
||||
}
|
||||
break;
|
||||
case 'has_any_category':
|
||||
addGroup('has_any_category', '(SELECT COUNT(*) FROM product_category_index WHERE product_category_index.pid=p.pid)>0'); break;
|
||||
case 'has_themes':
|
||||
addGroup('has_themes', `(SELECT COUNT(*) FROM product_category_index JOIN product_categories ON (product_category_index.cat_id=product_categories.cat_id AND product_categories.type IN (${CAT_THEMES})) WHERE product_category_index.pid=p.pid)>0`); break;
|
||||
case 'no_themes':
|
||||
addGroup('no_themes', `(SELECT COUNT(*) FROM product_category_index JOIN product_categories ON (product_category_index.cat_id=product_categories.cat_id AND product_categories.type IN (${CAT_THEMES})) WHERE product_category_index.pid=p.pid)=0`); break;
|
||||
case 'no_categories':
|
||||
addGroup('no_categories', `(SELECT COUNT(*) FROM product_category_index JOIN product_categories ON (product_category_index.cat_id=product_categories.cat_id AND product_categories.type IN (${CAT_CATEGORIES_NO_SECTION})) WHERE product_category_index.pid=p.pid)=0`); break;
|
||||
|
||||
// ── color: extra JOIN product_colors ──────────────────────────────────
|
||||
case 'color':
|
||||
addJoin('product_colors', 'JOIN product_colors ON (p.pid=product_colors.pid)');
|
||||
addGroup('color' + sqlOp, `product_colors.color${sqlOp}${parseInt(t1)||0}`, isNot); break;
|
||||
|
||||
// ── product_inventory: extra JOIN (GROUP BY p.pid covers duplicates) ──
|
||||
case 'cost':
|
||||
addJoin('product_inventory', 'JOIN product_inventory ON (product_inventory.pid=p.pid)');
|
||||
addGroup('cost' + sqlOp, numFilt('product_inventory.costeach', sqlOp, t1, t2)); break;
|
||||
case 'original_cost':
|
||||
addJoin('product_inventory', 'JOIN product_inventory ON (product_inventory.pid=p.pid)');
|
||||
addGroup('orig_cost' + sqlOp, numFilt('product_inventory.orig_costeach', sqlOp, t1, t2)); break;
|
||||
case 'total_product_value':
|
||||
addGroup('total_product_value' + sqlOp,
|
||||
`(SELECT SUM(product_inventory.costeach*product_inventory.count) FROM product_inventory WHERE product_inventory.pid=p.pid)${sqlOp}${parseFloat(t1)||0}`);
|
||||
break;
|
||||
|
||||
// ── shop_inventory: extra LEFT JOIN ───────────────────────────────────
|
||||
// All shop_* filters share alias 'shop_inv' so they add only one JOIN
|
||||
case 'buyable':
|
||||
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||
addGroup('shop_inv_cond', `shop_inv.store=${ACOT_STORE} AND shop_inv.buyable=1`); break;
|
||||
case 'not_buyable':
|
||||
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||
addGroup('shop_inv_cond', `shop_inv.store=${ACOT_STORE} AND shop_inv.buyable=0`); break;
|
||||
case 'shop_available':
|
||||
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||
addGroup('shop_inv_store', `shop_inv.store=${ACOT_STORE}`);
|
||||
addGroup('shop_inv_avail' + sqlOp, numFilt('shop_inv.available', sqlOp, t1, t2)); break;
|
||||
case 'shop_available_local': {
|
||||
addJoin('shop_inv_local', `LEFT JOIN shop_inventory AS shop_inv_local ON (p.pid=shop_inv_local.pid AND shop_inv_local.store=${ACOT_STORE})`);
|
||||
const filt = sqlOp === '<'
|
||||
? `(shop_inv_local.available_local${sqlOp}${parseFloat(t1)||0} OR shop_inv_local.available_local IS NULL)`
|
||||
: numFilt('shop_inv_local.available_local', sqlOp, t1, t2);
|
||||
addGroup('shop_avail_local' + sqlOp, filt); break;
|
||||
}
|
||||
case 'shop_show':
|
||||
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.show=1`); break;
|
||||
case 'shop_show_in':
|
||||
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||
addGroup('shop_inv_cond', `shop_inv.store IN (${strVal(t1)}) AND shop_inv.show=1`); break;
|
||||
case 'shop_buyable':
|
||||
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.buyable=1`); break;
|
||||
case 'shop_preorder':
|
||||
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.\`all\`=2`); break;
|
||||
case 'shop_not_preorder':
|
||||
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.\`all\`!=2`); break;
|
||||
case 'shop_inventory_source_acot':
|
||||
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source=${SRC_ACOT}`); break;
|
||||
case 'shop_inventory_source_acot_or_pre':
|
||||
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source IN (${SRC_ACOT},${SRC_PREORDER})`); break;
|
||||
case 'shop_inventory_source_notions':
|
||||
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source=${SRC_NOTIONS}`); break;
|
||||
case 'shop_inventory_source_not_notions':
|
||||
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source!=${SRC_NOTIONS}`); break;
|
||||
case 'preorder_item':
|
||||
addJoin('shop_inv_pre', `LEFT JOIN shop_inventory AS shop_inv_pre ON (p.pid=shop_inv_pre.pid AND shop_inv_pre.store=${ACOT_STORE})`);
|
||||
addGroup('preorder_item', `shop_inv_pre.inventory_source=${SRC_PREORDER}`); break;
|
||||
case 'not_preorder_item':
|
||||
addJoin('shop_inv_pre', `LEFT JOIN shop_inventory AS shop_inv_pre ON (p.pid=shop_inv_pre.pid AND shop_inv_pre.store=${ACOT_STORE})`);
|
||||
addGroup('preorder_item', `shop_inv_pre.inventory_source!=${SRC_PREORDER} OR shop_inv_pre.inventory_source IS NULL`); break;
|
||||
|
||||
// ── product_notions: extra JOIN ────────────────────────────────────────
|
||||
case 'notions_use_inventory':
|
||||
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||
addGroup('notions_use_inv', `product_notions.use_inventory${sqlOp}`); break;
|
||||
case 'notions_inventory':
|
||||
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||
addGroup('notions_inv', `product_notions.inventory${sqlOp}${parseInt(t1)||0}`); break;
|
||||
case 'notions_sell_qty':
|
||||
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||
addGroup('notions_sell_qty', `product_notions.sell_qty${sqlOp}${parseInt(t1)||0}`); break;
|
||||
case 'notions_csc':
|
||||
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||
addGroup('notions_csc',
|
||||
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||
? `product_notions.csc${sqlOp}'%${strVal(t1)}%'`
|
||||
: `product_notions.csc${sqlOp}'${strVal(t1)}'`);
|
||||
break;
|
||||
case 'notions_top_sellers':
|
||||
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||
addGroup('notions_top_sellers', 'product_notions.top_seller>0'); break;
|
||||
case 'notions_cost_higher_than_selling_price':
|
||||
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||
addGroup('notions_cost_vs_sell', '(product_notions.sell_cost*product_notions.sell_qty) > p.sellingprice'); break;
|
||||
case 'notions_order_qty_conversion_our':
|
||||
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||
addGroup('notion_oqc_our' + sqlOp, numFilt('product_notions.order_qty_conversion_our', sqlOp, t1, t2)); break;
|
||||
case 'notions_order_qty_conversion_notions':
|
||||
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||
addGroup('notion_oqc_notions' + sqlOp, numFilt('product_notions.order_qty_conversion_notions', sqlOp, t1, t2)); break;
|
||||
|
||||
// ── product_backorders: extra LEFT JOIN ────────────────────────────────
|
||||
case 'backorder_qty_any':
|
||||
addJoin('product_backorders', 'LEFT JOIN product_backorders ON (product_backorders.pid=p.pid)');
|
||||
break; // join only, no WHERE condition
|
||||
case 'backorder_qty_notions':
|
||||
addJoin('product_backorders', 'LEFT JOIN product_backorders ON (product_backorders.pid=p.pid)');
|
||||
addGroup('backorder_qty_notions', `product_backorders.qty${sqlOp}${parseInt(t1)||0} AND product_backorders.supplier=92`); break;
|
||||
|
||||
// ── product_related: extra JOIN ────────────────────────────────────────
|
||||
case 'related':
|
||||
addJoin('product_related', 'JOIN product_related ON (product_related.to_pid=p.pid)');
|
||||
addGroup('related_pid', `product_related.pid${sqlOp}${parseInt(t1)||0}`);
|
||||
if (t2) addGroup('related_type', `product_related.type=${parseInt(t2)||0}`);
|
||||
break;
|
||||
case 'no_relations':
|
||||
addJoin('product_related_nr', 'LEFT JOIN product_related AS product_related_nr ON (product_related_nr.pid=p.pid)');
|
||||
addGroup('no_relations', 'product_related_nr.to_pid IS NULL'); break;
|
||||
|
||||
// ── receivings / PO: extra INNER JOINs ────────────────────────────────
|
||||
case 'receiving_id':
|
||||
addJoin('receivings_products', 'JOIN receivings_products ON (p.pid=receivings_products.pid)');
|
||||
addGroup('receiving_id' + sqlOp, `receivings_products.receiving_id${sqlOp}'${strVal(t1)}'`, isNot); break;
|
||||
case 'po_id':
|
||||
addJoin('po_products', 'JOIN po_products ON (p.pid=po_products.pid)');
|
||||
addGroup('po_id' + sqlOp, `po_products.po_id${sqlOp}'${strVal(t1)}'`, isNot); break;
|
||||
|
||||
// ── subquery filters ───────────────────────────────────────────────────
|
||||
case 'missing_images':
|
||||
addGroup('missing_images',
|
||||
(row.filter_operator === 'true' || row.filter_operator === 'true1')
|
||||
? '(SELECT COUNT(*) FROM product_images WHERE product_images.pid=p.pid)=0'
|
||||
: '(SELECT COUNT(*) FROM product_images WHERE product_images.pid=p.pid)>0');
|
||||
break;
|
||||
case 'current_price':
|
||||
addGroup('current_price' + sqlOp,
|
||||
`(SELECT MIN(price_each) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)${sqlOp}${parseFloat(t1)||0}`);
|
||||
break;
|
||||
case 'current_price_sale_percent': {
|
||||
const v1 = (parseFloat(t1)||0) > 1 ? (parseFloat(t1)/100) : (parseFloat(t1)||0);
|
||||
const pe = '(SELECT MIN(price_each) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)';
|
||||
addGroup('current_price_sale_pct' + sqlOp, `(1 - ${pe} / p.sellingprice)${sqlOp}${v1}`); break;
|
||||
}
|
||||
case 'one_current_price':
|
||||
addGroup('one_current_price', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)=1'); break;
|
||||
case 'multiple_current_prices':
|
||||
addGroup('multiple_current_prices', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)>1'); break;
|
||||
case 'current_price_is_missing':
|
||||
addGroup('current_price_is_missing', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)=0'); break;
|
||||
case 'current_price_not_one_buyable':
|
||||
addGroup('current_price_not_one_buyable',
|
||||
'(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND qty_buy=1)=0 AND (SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)>0');
|
||||
break;
|
||||
case 'current_price_min_buy': {
|
||||
const cond = t2
|
||||
? `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=1 AND qty_buy${sqlOp}${parseFloat(t1)||0} AND ${parseFloat(t2)||0})>0`
|
||||
: `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=1 AND qty_buy${sqlOp}${parseFloat(t1)||0})>0`;
|
||||
addGroup('current_price_min_buy' + sqlOp, cond); break;
|
||||
}
|
||||
case 'current_price_each_buy': {
|
||||
const cond = t2
|
||||
? `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=0 AND qty_buy${sqlOp}${parseFloat(t1)||0} AND ${parseFloat(t2)||0})>0`
|
||||
: `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=0 AND qty_buy${sqlOp}${parseFloat(t1)||0})>0`;
|
||||
addGroup('current_price_each_buy' + sqlOp, cond); break;
|
||||
}
|
||||
case 'current_price_max_qty': {
|
||||
const cond = t2
|
||||
? `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND qty_limit${sqlOp}${parseFloat(t1)||0} AND ${parseFloat(t2)||0})>0`
|
||||
: `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND qty_limit${sqlOp}${parseFloat(t1)||0})>0`;
|
||||
addGroup('current_price_max_qty' + sqlOp, cond); break;
|
||||
}
|
||||
case 'current_price_is_checkout_offer':
|
||||
addGroup('current_price_checkout',
|
||||
`(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND checkout_offer${sqlOp})>0`);
|
||||
break;
|
||||
case 'current_price_has_extra':
|
||||
addJoin('cp_extras', 'JOIN (SELECT pid,count(*) ct FROM product_current_prices WHERE active=1 GROUP BY pid,qty_buy HAVING ct>1) AS cp_extras ON (cp_extras.pid=p.pid)');
|
||||
addGroup('cp_extra', 'cp_extras.ct>1'); break;
|
||||
case 'has_video':
|
||||
addGroup('has_video', '(SELECT COUNT(*) FROM product_media WHERE product_media.pid=p.pid)>0'); break;
|
||||
case 'notions_created':
|
||||
addGroup('notions_created',
|
||||
sqlOp !== '=0'
|
||||
? '(SELECT COUNT(*) FROM product_notions_created WHERE product_notions_created.pid=p.pid)>0'
|
||||
: '(SELECT COUNT(*) FROM product_notions_created WHERE product_notions_created.pid=p.pid)=0');
|
||||
break;
|
||||
case 'notifications':
|
||||
addJoin('pnc', 'JOIN (SELECT pid,COUNT(*) AS notifications_count FROM product_notify GROUP BY pid) pnc ON (pnc.pid=p.pid)');
|
||||
addGroup('notifications', numFilt('pnc.notifications_count', sqlOp, t1, t2)); break;
|
||||
case 'daily_deal':
|
||||
addGroup('daily_deal', '(SELECT deal_id FROM product_daily_deals WHERE deal_date=CURDATE() AND product_daily_deals.pid=p.pid)>0');
|
||||
break;
|
||||
|
||||
// ── amazon_price (store id 5, legacy) ─────────────────────────────────
|
||||
case 'amazon_price': {
|
||||
const AMAZON_STORE = 5;
|
||||
addJoin('product_prices', 'LEFT JOIN product_prices ON (p.pid=product_prices.pid)');
|
||||
let filt = `product_prices.store=${AMAZON_STORE} AND product_prices.price${sqlOp}${parseFloat(t1)||0}`;
|
||||
if (t2) filt += ` AND ${parseFloat(t2)||0}`;
|
||||
if ((sqlOp === '<=' || sqlOp === '=') && t1 === '0') filt += ' OR product_prices.price IS NULL';
|
||||
addGroup('amazon_price' + sqlOp, filt); break;
|
||||
}
|
||||
|
||||
// ── basket (cid must be in filter_text2; skip if not set) ─────────────
|
||||
case 'basket':
|
||||
if (!t2) { unsupported.push('basket(no-cid)'); break; }
|
||||
addJoin('mybasket', 'JOIN mybasket ON (p.pid=mybasket.item)');
|
||||
addGroup('basket_cid', `mybasket.cid=${parseInt(t2)||0}`);
|
||||
addGroup('basket_sid', `mybasket.sid=0`); // ACOT store
|
||||
addGroup('basket_bid', t1 ? `mybasket.bid=${parseInt(t1)||0}` : 'mybasket.bid=0');
|
||||
if (t1 === '2') addGroup('basket_qty', 'mybasket.qty>0');
|
||||
break;
|
||||
|
||||
// ── hot: recent sales aggregation ─────────────────────────────────────
|
||||
case 'hot': {
|
||||
const days = parseInt(t1) || 30;
|
||||
const alias = `hot_${days}`;
|
||||
addJoin(alias, `JOIN (SELECT prod_pid, COUNT(*) hot_ct, SUM(order_items.qty_ordered) hot_sm FROM order_items JOIN _order ON (_order.order_id=order_items.order_id AND _order.order_status>60 AND _order.date_placed BETWEEN NOW()-INTERVAL ${days} DAY AND NOW()) GROUP BY order_items.prod_pid) ${alias} ON (p.pid=${alias}.prod_pid)`);
|
||||
break; // join alone filters to products with recent sales; no extra WHERE needed
|
||||
}
|
||||
|
||||
// Intentionally skipped (require external services or missing runtime context):
|
||||
// search/search_any (MeiliSearch API), basket without cid (per-session user),
|
||||
// groupby (display aggregation, not a filter)
|
||||
default:
|
||||
unsupported.push(row.filter_type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble WHERE: groups are AND-connected by default.
|
||||
// A group with ororor=true is OR-connected to the previous group instead (PHP filter_or mechanism).
|
||||
let whereClause = '';
|
||||
let i = 0;
|
||||
for (const [, group] of filterGroups) {
|
||||
const wrapped = group.conditions.map(c => `(${c})`);
|
||||
const innerJoiner = group.isNot ? ' AND ' : ' OR ';
|
||||
const part = `(${wrapped.join(innerJoiner)})`;
|
||||
if (i > 0) {
|
||||
whereClause += group.ororor ? ` OR ${part}` : ` AND ${part}`;
|
||||
} else {
|
||||
whereClause += part;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
const joinClauses = [...joinTables.values()].join('\n ');
|
||||
return { whereClause, joinClauses, unsupported };
|
||||
}
|
||||
|
||||
// Load products matching a saved product_query by query_id
|
||||
// Filter types whose v1/v2 values are cat_ids in product_categories
|
||||
const CAT_ID_FILTER_TYPES = new Set([
|
||||
'company', 'line', 'subline', 'artist', 'category', 'theme',
|
||||
'size_cat', 'dimension', 'yarn_weight', 'material',
|
||||
]);
|
||||
|
||||
async function resolveFilterLabels(connection, filters) {
|
||||
const catIds = new Set();
|
||||
const supplierIds = new Set();
|
||||
const taxCodeIds = new Set();
|
||||
|
||||
for (const f of filters) {
|
||||
const vals = [f.v1, f.v2].filter(v => v && !isNaN(Number(v)));
|
||||
if (CAT_ID_FILTER_TYPES.has(f.type)) vals.forEach(v => catIds.add(Number(v)));
|
||||
else if (f.type === 'investor') vals.forEach(v => supplierIds.add(Number(v)));
|
||||
else if (f.type === 'tax_code') vals.forEach(v => taxCodeIds.add(Number(v)));
|
||||
}
|
||||
|
||||
const catLookup = {};
|
||||
const supplierLookup = {};
|
||||
const taxCodeLookup = {};
|
||||
|
||||
if (catIds.size) {
|
||||
const ids = [...catIds];
|
||||
const [rows] = await connection.query(
|
||||
`SELECT cat_id, name FROM product_categories WHERE cat_id IN (${ids.map(() => '?').join(',')})`, ids
|
||||
);
|
||||
for (const r of rows) catLookup[r.cat_id] = r.name;
|
||||
}
|
||||
if (supplierIds.size) {
|
||||
const ids = [...supplierIds];
|
||||
const [rows] = await connection.query(
|
||||
`SELECT supplierid, companyname FROM suppliers WHERE supplierid IN (${ids.map(() => '?').join(',')})`, ids
|
||||
);
|
||||
for (const r of rows) supplierLookup[r.supplierid] = r.companyname;
|
||||
}
|
||||
if (taxCodeIds.size) {
|
||||
const ids = [...taxCodeIds];
|
||||
const [rows] = await connection.query(
|
||||
`SELECT tax_code_id, name FROM product_tax_codes WHERE tax_code_id IN (${ids.map(() => '?').join(',')})`, ids
|
||||
);
|
||||
for (const r of rows) taxCodeLookup[r.tax_code_id] = r.name;
|
||||
}
|
||||
|
||||
function labelFor(type, val) {
|
||||
if (!val || isNaN(Number(val))) return null;
|
||||
const id = Number(val);
|
||||
if (CAT_ID_FILTER_TYPES.has(type)) return catLookup[id] ?? null;
|
||||
if (type === 'investor') return supplierLookup[id] ?? null;
|
||||
if (type === 'tax_code') return taxCodeLookup[id] ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
return filters.map(f => ({
|
||||
...f,
|
||||
v1Label: labelFor(f.type, f.v1),
|
||||
v2Label: labelFor(f.type, f.v2),
|
||||
}));
|
||||
}
|
||||
|
||||
router.get('/query-products', async (req, res) => {
|
||||
const { query_id } = req.query;
|
||||
if (!query_id || isNaN(parseInt(query_id))) {
|
||||
return res.status(400).json({ error: 'Valid query_id is required' });
|
||||
}
|
||||
const qid = parseInt(query_id);
|
||||
|
||||
try {
|
||||
const { connection } = await getDbConnection();
|
||||
|
||||
// Verify query exists
|
||||
const [queryRows] = await connection.query(
|
||||
'SELECT id, name FROM product_query WHERE id = ?', [qid]
|
||||
);
|
||||
if (!queryRows.length) {
|
||||
return res.status(404).json({ error: `Query ${qid} not found` });
|
||||
}
|
||||
|
||||
// Load all filters for this query
|
||||
const [filterRows] = await connection.query(
|
||||
'SELECT * FROM product_query_filter WHERE query_id = ? ORDER BY id', [qid]
|
||||
);
|
||||
|
||||
if (!filterRows.length) {
|
||||
return res.json({ results: [], filters: [] });
|
||||
}
|
||||
|
||||
const { whereClause, joinClauses, unsupported } = buildQueryFilterSql(filterRows);
|
||||
|
||||
const uniqueUnsupported = [...new Set(unsupported)];
|
||||
if (uniqueUnsupported.length) {
|
||||
console.warn(`query-products: unsupported filter types for query ${qid}:`, uniqueUnsupported);
|
||||
}
|
||||
res.setHeader('X-Query-Name', queryRows[0].name || '');
|
||||
|
||||
function tryDecode(v) {
|
||||
if (!v) return null;
|
||||
try { return decodeURIComponent(String(v)); } catch { return String(v); }
|
||||
}
|
||||
const rawFilters = filterRows
|
||||
.filter(r => !uniqueUnsupported.includes(r.filter_type))
|
||||
.map(r => ({
|
||||
type: r.filter_type,
|
||||
op: r.filter_operator,
|
||||
v1: tryDecode(r.filter_text1),
|
||||
v2: tryDecode(r.filter_text2),
|
||||
}));
|
||||
|
||||
const filters = await resolveFilterLabels(connection, rawFilters);
|
||||
|
||||
// If all filters were unsupported the WHERE clause is empty — return nothing
|
||||
// rather than dumping the entire products table.
|
||||
if (!whereClause) {
|
||||
return res.json({ results: [], filters, unsupported: uniqueUnsupported });
|
||||
}
|
||||
|
||||
const sql = `${PRODUCT_SELECT}
|
||||
${joinClauses}
|
||||
WHERE ${whereClause}
|
||||
GROUP BY p.pid
|
||||
ORDER BY p.description
|
||||
LIMIT 2000`;
|
||||
|
||||
const [results] = await connection.query(sql);
|
||||
res.json({ results, filters, unsupported: uniqueUnsupported });
|
||||
} catch (error) {
|
||||
console.error('Error loading query products:', error);
|
||||
res.status(500).json({ error: 'Failed to load query products', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -43,7 +43,6 @@ const COLUMN_MAP = {
|
||||
currentPrice: 'pm.current_price',
|
||||
currentRegularPrice: 'pm.current_regular_price',
|
||||
currentCostPrice: 'pm.current_cost_price',
|
||||
currentLandingCostPrice: 'pm.current_landing_cost_price',
|
||||
currentStock: 'pm.current_stock',
|
||||
currentStockCost: 'pm.current_stock_cost',
|
||||
currentStockRetail: 'pm.current_stock_retail',
|
||||
@@ -176,7 +175,7 @@ const COLUMN_MAP = {
|
||||
const COLUMN_TYPES = {
|
||||
// Numeric columns (use numeric operators and sorting)
|
||||
numeric: [
|
||||
'pid', 'currentPrice', 'currentRegularPrice', 'currentCostPrice', 'currentLandingCostPrice',
|
||||
'pid', 'currentPrice', 'currentRegularPrice', 'currentCostPrice',
|
||||
'currentStock', 'currentStockCost', 'currentStockRetail', 'currentStockGross',
|
||||
'onOrderQty', 'onOrderCost', 'onOrderRetail', 'ageDays',
|
||||
'sales7d', 'revenue7d', 'sales14d', 'revenue14d', 'sales30d', 'revenue30d',
|
||||
|
||||
@@ -145,7 +145,6 @@ router.get('/', async (req, res) => {
|
||||
stock: 'p.stock_quantity',
|
||||
price: 'p.price',
|
||||
costPrice: 'p.cost_price',
|
||||
landingCost: 'p.landing_cost_price',
|
||||
dailySalesAvg: 'pm.daily_sales_avg',
|
||||
weeklySalesAvg: 'pm.weekly_sales_avg',
|
||||
monthlySalesAvg: 'pm.monthly_sales_avg',
|
||||
@@ -464,31 +463,41 @@ router.get('/search', async (req, res) => {
|
||||
|
||||
try {
|
||||
const terms = q.trim().split(/\s+/).filter(Boolean);
|
||||
// Each term must match at least one of: title, sku, barcode, brand, vendor
|
||||
// Each term must match at least one of: title, sku, barcode, brand, vendor, vendor_reference, notions_reference, line, subline, artist
|
||||
const conditions = terms.map((_, i) => {
|
||||
const p = i * 5;
|
||||
return `(p.title ILIKE $${p + 1} OR p.sku ILIKE $${p + 2} OR p.barcode ILIKE $${p + 3} OR p.brand ILIKE $${p + 4} OR p.vendor ILIKE $${p + 5})`;
|
||||
const p = i * 10;
|
||||
return `(p.title ILIKE $${p + 1} OR p.sku ILIKE $${p + 2} OR p.barcode ILIKE $${p + 3} OR p.brand ILIKE $${p + 4} OR p.vendor ILIKE $${p + 5} OR p.vendor_reference ILIKE $${p + 6} OR p.notions_reference ILIKE $${p + 7} OR p.line ILIKE $${p + 8} OR p.subline ILIKE $${p + 9} OR p.artist ILIKE $${p + 10})`;
|
||||
});
|
||||
const params = terms.flatMap(t => {
|
||||
const like = `%${t}%`;
|
||||
return [like, like, like, like, like];
|
||||
return [like, like, like, like, like, like, like, like, like, like];
|
||||
});
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT pid, title, sku, barcode, brand, line, regular_price, image_175
|
||||
FROM products p
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY
|
||||
CASE WHEN p.sku ILIKE $${params.length + 1} THEN 0
|
||||
WHEN p.barcode ILIKE $${params.length + 1} THEN 1
|
||||
WHEN p.title ILIKE $${params.length + 1} THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
p.total_sold DESC NULLS LAST
|
||||
LIMIT 50
|
||||
`, [...params, `%${q.trim()}%`]);
|
||||
const whereClause = conditions.join(' AND ');
|
||||
const searchParams = [...params, `%${q.trim()}%`];
|
||||
|
||||
res.json(rows);
|
||||
const [{ rows }, { rows: countRows }] = await Promise.all([
|
||||
pool.query(`
|
||||
SELECT pid, title, sku, barcode, brand, line, regular_price, image_175
|
||||
FROM products p
|
||||
WHERE ${whereClause}
|
||||
ORDER BY
|
||||
CASE WHEN p.sku ILIKE $${params.length + 1} THEN 0
|
||||
WHEN p.barcode ILIKE $${params.length + 1} THEN 1
|
||||
WHEN p.title ILIKE $${params.length + 1} THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
p.total_sold DESC NULLS LAST
|
||||
LIMIT 100
|
||||
`, searchParams),
|
||||
pool.query(`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM products p
|
||||
WHERE ${whereClause}
|
||||
`, params),
|
||||
]);
|
||||
|
||||
res.json({ results: rows, total: countRows[0].total });
|
||||
} catch (error) {
|
||||
console.error('Error searching products:', error);
|
||||
res.status(500).json({ error: 'Search failed' });
|
||||
@@ -621,7 +630,6 @@ router.get('/:id', async (req, res) => {
|
||||
price: parseFloat(productRows[0].price),
|
||||
regular_price: parseFloat(productRows[0].regular_price),
|
||||
cost_price: parseFloat(productRows[0].cost_price),
|
||||
landing_cost_price: parseFloat(productRows[0].landing_cost_price),
|
||||
stock_quantity: parseInt(productRows[0].stock_quantity),
|
||||
moq: parseInt(productRows[0].moq),
|
||||
uom: parseInt(productRows[0].uom),
|
||||
@@ -784,4 +792,49 @@ router.get('/:id/time-series', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /products/:id/forecast
|
||||
// Returns the 90-day daily forecast for a single product from product_forecasts
|
||||
router.get('/:id/forecast', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
forecast_date AS date,
|
||||
forecast_units AS units,
|
||||
forecast_revenue AS revenue,
|
||||
lifecycle_phase AS phase,
|
||||
forecast_method AS method,
|
||||
confidence_lower,
|
||||
confidence_upper
|
||||
FROM product_forecasts
|
||||
WHERE pid = $1
|
||||
ORDER BY forecast_date
|
||||
`, [id]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.json({ forecast: [], phase: null, method: null });
|
||||
}
|
||||
|
||||
const phase = rows[0].phase;
|
||||
const method = rows[0].method;
|
||||
|
||||
res.json({
|
||||
phase,
|
||||
method,
|
||||
forecast: rows.map(r => ({
|
||||
date: r.date instanceof Date ? r.date.toISOString().split('T')[0] : r.date,
|
||||
units: parseFloat(r.units) || 0,
|
||||
revenue: parseFloat(r.revenue) || 0,
|
||||
confidenceLower: parseFloat(r.confidence_lower) || 0,
|
||||
confidenceUpper: parseFloat(r.confidence_upper) || 0,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching product forecast:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch product forecast' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1185,4 +1185,96 @@ router.get('/delivery-metrics', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// PO Pipeline — expected arrivals timeline + overdue summary
|
||||
router.get('/pipeline', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
// Stale PO filter (reused across queries)
|
||||
const staleFilter = `
|
||||
WITH stale AS (
|
||||
SELECT po_id, pid
|
||||
FROM purchase_orders po
|
||||
WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent',
|
||||
'electronically_ready_send', 'receiving_started')
|
||||
AND po.expected_date IS NOT NULL
|
||||
AND po.expected_date < CURRENT_DATE - INTERVAL '90 days'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM purchase_orders newer
|
||||
WHERE newer.pid = po.pid
|
||||
AND newer.status NOT IN ('canceled', 'done')
|
||||
AND COALESCE(newer.date_ordered, newer.date_created)
|
||||
> COALESCE(po.date_ordered, po.date_created)
|
||||
)
|
||||
)`;
|
||||
|
||||
// Expected arrivals by week (excludes stale POs)
|
||||
const { rows: arrivals } = await pool.query(`
|
||||
${staleFilter}
|
||||
SELECT
|
||||
DATE_TRUNC('week', po.expected_date)::date AS week,
|
||||
COUNT(DISTINCT po.po_id) AS po_count,
|
||||
ROUND(SUM(po.po_cost_price * po.ordered)::numeric, 0) AS expected_value,
|
||||
COUNT(DISTINCT po.vendor) AS vendor_count
|
||||
FROM purchase_orders po
|
||||
WHERE po.status IN ('ordered', 'electronically_sent')
|
||||
AND po.expected_date IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`);
|
||||
|
||||
// Overdue POs (excludes stale)
|
||||
const { rows: [overdue] } = await pool.query(`
|
||||
${staleFilter}
|
||||
SELECT
|
||||
COUNT(DISTINCT po.po_id) AS po_count,
|
||||
ROUND(COALESCE(SUM(po.po_cost_price * po.ordered), 0)::numeric, 0) AS total_value
|
||||
FROM purchase_orders po
|
||||
WHERE po.status IN ('ordered', 'electronically_sent')
|
||||
AND po.expected_date IS NOT NULL
|
||||
AND po.expected_date < CURRENT_DATE
|
||||
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
|
||||
`);
|
||||
|
||||
// Summary: on-order value from product_metrics (FIFO-accurate), PO counts from purchase_orders with staleness filter
|
||||
const { rows: [summary] } = await pool.query(`
|
||||
${staleFilter}
|
||||
SELECT
|
||||
COUNT(DISTINCT po.po_id) AS total_open_pos,
|
||||
COUNT(DISTINCT po.vendor) AS vendor_count
|
||||
FROM purchase_orders po
|
||||
WHERE po.status IN ('ordered', 'electronically_sent')
|
||||
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
|
||||
`);
|
||||
|
||||
const { rows: [onOrderTotal] } = await pool.query(`
|
||||
SELECT ROUND(COALESCE(SUM(on_order_cost), 0)::numeric, 0) AS total_on_order_value
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
`);
|
||||
|
||||
res.json({
|
||||
arrivals: arrivals.map(r => ({
|
||||
week: r.week,
|
||||
poCount: Number(r.po_count) || 0,
|
||||
expectedValue: Number(r.expected_value) || 0,
|
||||
vendorCount: Number(r.vendor_count) || 0,
|
||||
})),
|
||||
overdue: {
|
||||
count: Number(overdue.po_count) || 0,
|
||||
value: Number(overdue.total_value) || 0,
|
||||
},
|
||||
summary: {
|
||||
totalOpenPOs: Number(summary.total_open_pos) || 0,
|
||||
totalOnOrderValue: Number(onOrderTotal.total_on_order_value) || 0,
|
||||
vendorCount: Number(summary.vendor_count) || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching PO pipeline:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch PO pipeline' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -162,6 +162,8 @@ async function startServer() {
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
|
||||
// Pre-warm AI service so taxonomy embeddings are ready before first user request
|
||||
aiRouter.initInBackground();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
|
||||
@@ -3,13 +3,26 @@
|
||||
*
|
||||
* Generates and caches embeddings for categories, themes, and colors.
|
||||
* Excludes "Black Friday", "Gifts", "Deals" categories and their children.
|
||||
*
|
||||
* Disk cache: embeddings are saved to data/taxonomy-embeddings.json and reused
|
||||
* across server restarts. Cache is invalidated by content hash — if the taxonomy
|
||||
* rows in MySQL change, the next check will detect it and regenerate automatically.
|
||||
*
|
||||
* Background check: after initialization, call startBackgroundCheck(getConnectionFn)
|
||||
* to poll for taxonomy changes on a configurable interval (default 1h).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { findTopMatches } = require('./similarity');
|
||||
|
||||
// Categories to exclude (and all their children)
|
||||
const EXCLUDED_CATEGORY_NAMES = ['black friday', 'gifts', 'deals'];
|
||||
|
||||
// Disk cache config
|
||||
const CACHE_PATH = path.join(__dirname, '..', '..', '..', '..', 'data', 'taxonomy-embeddings.json');
|
||||
|
||||
class TaxonomyEmbeddings {
|
||||
constructor({ provider, logger }) {
|
||||
this.provider = provider;
|
||||
@@ -25,12 +38,18 @@ class TaxonomyEmbeddings {
|
||||
this.themeMap = new Map();
|
||||
this.colorMap = new Map();
|
||||
|
||||
// Content hash of the last successfully built taxonomy (from DB rows)
|
||||
this.contentHash = null;
|
||||
|
||||
this.initialized = false;
|
||||
this.initializing = false;
|
||||
this._checkInterval = null;
|
||||
this._regenerating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize embeddings - fetch taxonomy and generate embeddings
|
||||
* Initialize embeddings — fetches raw taxonomy rows to compute a content hash,
|
||||
* then either loads the matching disk cache or generates fresh embeddings.
|
||||
*/
|
||||
async initialize(connection) {
|
||||
if (this.initialized) {
|
||||
@@ -48,42 +67,36 @@ class TaxonomyEmbeddings {
|
||||
this.initializing = true;
|
||||
|
||||
try {
|
||||
this.logger.info('[TaxonomyEmbeddings] Starting initialization...');
|
||||
// Always fetch raw rows first — cheap (~10ms), no OpenAI calls.
|
||||
// Used to compute a content hash for cache validation.
|
||||
const rawRows = await this._fetchRawRows(connection);
|
||||
const freshHash = this._computeContentHash(rawRows);
|
||||
|
||||
// Fetch raw taxonomy data
|
||||
const [categories, themes, colors] = await Promise.all([
|
||||
this._fetchCategories(connection),
|
||||
this._fetchThemes(connection),
|
||||
this._fetchColors(connection)
|
||||
]);
|
||||
const cached = this._loadCache();
|
||||
if (cached && cached.contentHash === freshHash) {
|
||||
this.categories = cached.categories;
|
||||
this.themes = cached.themes;
|
||||
this.colors = cached.colors;
|
||||
this.categoryMap = new Map(this.categories.map(c => [c.id, c]));
|
||||
this.themeMap = new Map(this.themes.map(t => [t.id, t]));
|
||||
this.colorMap = new Map(this.colors.map(c => [c.id, c]));
|
||||
this.contentHash = freshHash;
|
||||
this.initialized = true;
|
||||
this.logger.info(`[TaxonomyEmbeddings] Loaded from cache: ${this.categories.length} categories, ${this.themes.length} themes, ${this.colors.length} colors`);
|
||||
return { categories: this.categories.length, themes: this.themes.length, colors: this.colors.length };
|
||||
}
|
||||
|
||||
this.logger.info(`[TaxonomyEmbeddings] Fetched ${categories.length} categories, ${themes.length} themes, ${colors.length} colors`);
|
||||
|
||||
// Generate embeddings in parallel
|
||||
const [catEmbeddings, themeEmbeddings, colorEmbeddings] = await Promise.all([
|
||||
this._generateEmbeddings(categories, 'categories'),
|
||||
this._generateEmbeddings(themes, 'themes'),
|
||||
this._generateEmbeddings(colors, 'colors')
|
||||
]);
|
||||
|
||||
// Store with embeddings
|
||||
this.categories = catEmbeddings;
|
||||
this.themes = themeEmbeddings;
|
||||
this.colors = colorEmbeddings;
|
||||
|
||||
// Build lookup maps
|
||||
this.categoryMap = new Map(this.categories.map(c => [c.id, c]));
|
||||
this.themeMap = new Map(this.themes.map(t => [t.id, t]));
|
||||
this.colorMap = new Map(this.colors.map(c => [c.id, c]));
|
||||
if (cached) {
|
||||
this.logger.info('[TaxonomyEmbeddings] Taxonomy changed since cache was built, regenerating...');
|
||||
} else {
|
||||
this.logger.info('[TaxonomyEmbeddings] No cache — fetching taxonomy and generating embeddings...');
|
||||
}
|
||||
|
||||
await this._buildAndEmbed(rawRows, freshHash);
|
||||
this.initialized = true;
|
||||
this.logger.info('[TaxonomyEmbeddings] Initialization complete');
|
||||
|
||||
return {
|
||||
categories: this.categories.length,
|
||||
themes: this.themes.length,
|
||||
colors: this.colors.length
|
||||
};
|
||||
return { categories: this.categories.length, themes: this.themes.length, colors: this.colors.length };
|
||||
} catch (error) {
|
||||
this.logger.error('[TaxonomyEmbeddings] Initialization failed:', error);
|
||||
throw error;
|
||||
@@ -92,6 +105,47 @@ class TaxonomyEmbeddings {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a background interval that checks for taxonomy changes and regenerates
|
||||
* embeddings automatically if the content hash differs.
|
||||
*
|
||||
* @param {Function} getConnectionFn - async function returning { connection }
|
||||
* @param {number} intervalMs - check interval, default 1 hour
|
||||
*/
|
||||
startBackgroundCheck(getConnectionFn, intervalMs = 60 * 60 * 1000) {
|
||||
if (this._checkInterval) return;
|
||||
|
||||
this.logger.info(`[TaxonomyEmbeddings] Background taxonomy check started (every ${intervalMs / 60000} min)`);
|
||||
|
||||
this._checkInterval = setInterval(async () => {
|
||||
if (this._regenerating) return;
|
||||
|
||||
try {
|
||||
const { connection } = await getConnectionFn();
|
||||
const rawRows = await this._fetchRawRows(connection);
|
||||
const freshHash = this._computeContentHash(rawRows);
|
||||
|
||||
if (freshHash === this.contentHash) return;
|
||||
|
||||
this.logger.info('[TaxonomyEmbeddings] Taxonomy changed, regenerating embeddings in background...');
|
||||
this._regenerating = true;
|
||||
await this._buildAndEmbed(rawRows, freshHash);
|
||||
this.logger.info('[TaxonomyEmbeddings] Background regeneration complete');
|
||||
} catch (err) {
|
||||
this.logger.warn('[TaxonomyEmbeddings] Background taxonomy check failed:', err.message);
|
||||
} finally {
|
||||
this._regenerating = false;
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stopBackgroundCheck() {
|
||||
if (this._checkInterval) {
|
||||
clearInterval(this._checkInterval);
|
||||
this._checkInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar categories for a product embedding
|
||||
*/
|
||||
@@ -176,29 +230,74 @@ class TaxonomyEmbeddings {
|
||||
// Private Methods
|
||||
// ============================================================================
|
||||
|
||||
async _fetchCategories(connection) {
|
||||
// Fetch hierarchical categories (types 10-13)
|
||||
const [rows] = await connection.query(`
|
||||
SELECT cat_id, name, master_cat_id, type
|
||||
FROM product_categories
|
||||
WHERE type IN (10, 11, 12, 13)
|
||||
ORDER BY type, name
|
||||
`);
|
||||
/**
|
||||
* Fetch minimal raw rows from MySQL — used for content hash computation.
|
||||
* This is the cheap path: no path-building, no embeddings, just the raw data.
|
||||
*/
|
||||
async _fetchRawRows(connection) {
|
||||
const [[catRows], [themeRows], [colorRows]] = await Promise.all([
|
||||
connection.query('SELECT cat_id, name, master_cat_id, type FROM product_categories WHERE type IN (10, 11, 12, 13) ORDER BY cat_id'),
|
||||
connection.query('SELECT cat_id, name, master_cat_id, type FROM product_categories WHERE type IN (20, 21) ORDER BY cat_id'),
|
||||
connection.query('SELECT color, name, hex_color FROM product_color_list ORDER BY `order`')
|
||||
]);
|
||||
return { catRows, themeRows, colorRows };
|
||||
}
|
||||
|
||||
// Build lookup for hierarchy
|
||||
/**
|
||||
* Compute a stable SHA-256 hash of the taxonomy row content.
|
||||
* Any change to IDs, names, or parent relationships will produce a different hash.
|
||||
*/
|
||||
_computeContentHash({ catRows, themeRows, colorRows }) {
|
||||
const content = JSON.stringify({
|
||||
cats: catRows.map(r => [r.cat_id, r.name, r.master_cat_id]).sort((a, b) => a[0] - b[0]),
|
||||
themes: themeRows.map(r => [r.cat_id, r.name, r.master_cat_id]).sort((a, b) => a[0] - b[0]),
|
||||
colors: colorRows.map(r => [r.color, r.name]).sort()
|
||||
});
|
||||
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build full taxonomy objects and generate embeddings, then atomically swap
|
||||
* the in-memory state. Called on cache miss and on background change detection.
|
||||
*/
|
||||
async _buildAndEmbed(rawRows, contentHash) {
|
||||
const { catRows, themeRows, colorRows } = rawRows;
|
||||
|
||||
const categories = this._buildCategories(catRows);
|
||||
const themes = this._buildThemes(themeRows);
|
||||
const colors = this._buildColors(colorRows);
|
||||
|
||||
this.logger.info(`[TaxonomyEmbeddings] Generating embeddings for ${categories.length} categories, ${themes.length} themes, ${colors.length} colors`);
|
||||
|
||||
const [catEmbeddings, themeEmbeddings, colorEmbeddings] = await Promise.all([
|
||||
this._generateEmbeddings(categories, 'categories'),
|
||||
this._generateEmbeddings(themes, 'themes'),
|
||||
this._generateEmbeddings(colors, 'colors')
|
||||
]);
|
||||
|
||||
// Atomic in-memory swap (single-threaded JS — readers always see a consistent state)
|
||||
this.categories = catEmbeddings;
|
||||
this.themes = themeEmbeddings;
|
||||
this.colors = colorEmbeddings;
|
||||
this.categoryMap = new Map(this.categories.map(c => [c.id, c]));
|
||||
this.themeMap = new Map(this.themes.map(t => [t.id, t]));
|
||||
this.colorMap = new Map(this.colors.map(c => [c.id, c]));
|
||||
this.contentHash = contentHash;
|
||||
|
||||
this._saveCache();
|
||||
}
|
||||
|
||||
_buildCategories(rows) {
|
||||
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
||||
|
||||
// Find IDs of excluded top-level categories and all their descendants
|
||||
const excludedIds = new Set();
|
||||
|
||||
// First pass: find excluded top-level categories
|
||||
for (const row of rows) {
|
||||
if (row.type === 10 && EXCLUDED_CATEGORY_NAMES.includes(row.name.toLowerCase())) {
|
||||
excludedIds.add(row.cat_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple passes to find all descendants
|
||||
// Multiple passes to find all descendants of excluded categories
|
||||
let foundNew = true;
|
||||
while (foundNew) {
|
||||
foundNew = false;
|
||||
@@ -212,20 +311,14 @@ class TaxonomyEmbeddings {
|
||||
|
||||
this.logger.info(`[TaxonomyEmbeddings] Excluding ${excludedIds.size} categories (Black Friday, Gifts, Deals and children)`);
|
||||
|
||||
// Build category objects with full paths, excluding filtered ones
|
||||
const categories = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (excludedIds.has(row.cat_id)) {
|
||||
continue;
|
||||
}
|
||||
if (excludedIds.has(row.cat_id)) continue;
|
||||
|
||||
const path = [];
|
||||
const pathParts = [];
|
||||
let current = row;
|
||||
|
||||
// Walk up the tree to build full path
|
||||
while (current) {
|
||||
path.unshift(current.name);
|
||||
pathParts.unshift(current.name);
|
||||
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
||||
}
|
||||
|
||||
@@ -234,55 +327,37 @@ class TaxonomyEmbeddings {
|
||||
name: row.name,
|
||||
parentId: row.master_cat_id,
|
||||
type: row.type,
|
||||
fullPath: path.join(' > '),
|
||||
embeddingText: path.join(' ')
|
||||
fullPath: pathParts.join(' > '),
|
||||
embeddingText: pathParts.join(' ')
|
||||
});
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
async _fetchThemes(connection) {
|
||||
// Fetch themes (types 20-21)
|
||||
const [rows] = await connection.query(`
|
||||
SELECT cat_id, name, master_cat_id, type
|
||||
FROM product_categories
|
||||
WHERE type IN (20, 21)
|
||||
ORDER BY type, name
|
||||
`);
|
||||
|
||||
_buildThemes(rows) {
|
||||
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
||||
const themes = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const path = [];
|
||||
return rows.map(row => {
|
||||
const pathParts = [];
|
||||
let current = row;
|
||||
|
||||
while (current) {
|
||||
path.unshift(current.name);
|
||||
pathParts.unshift(current.name);
|
||||
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
||||
}
|
||||
|
||||
themes.push({
|
||||
return {
|
||||
id: row.cat_id,
|
||||
name: row.name,
|
||||
parentId: row.master_cat_id,
|
||||
type: row.type,
|
||||
fullPath: path.join(' > '),
|
||||
embeddingText: path.join(' ')
|
||||
});
|
||||
}
|
||||
|
||||
return themes;
|
||||
fullPath: pathParts.join(' > '),
|
||||
embeddingText: pathParts.join(' ')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async _fetchColors(connection) {
|
||||
const [rows] = await connection.query(`
|
||||
SELECT color, name, hex_color
|
||||
FROM product_color_list
|
||||
ORDER BY \`order\`
|
||||
`);
|
||||
|
||||
_buildColors(rows) {
|
||||
return rows.map(row => ({
|
||||
id: row.color,
|
||||
name: row.name,
|
||||
@@ -301,9 +376,7 @@ class TaxonomyEmbeddings {
|
||||
const results = [...items];
|
||||
|
||||
// Process in batches
|
||||
let batchNum = 0;
|
||||
for await (const chunk of this.provider.embedBatchChunked(texts, { batchSize: 100 })) {
|
||||
batchNum++;
|
||||
for (let i = 0; i < chunk.embeddings.length; i++) {
|
||||
const globalIndex = chunk.startIndex + i;
|
||||
results[globalIndex] = {
|
||||
@@ -318,6 +391,43 @@ class TaxonomyEmbeddings {
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Disk Cache Methods
|
||||
// ============================================================================
|
||||
|
||||
_loadCache() {
|
||||
try {
|
||||
if (!fs.existsSync(CACHE_PATH)) return null;
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
|
||||
if (!data.contentHash || !data.categories?.length || !data.themes?.length || !data.colors?.length) {
|
||||
this.logger.warn('[TaxonomyEmbeddings] Disk cache malformed or missing content hash, will regenerate');
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
this.logger.warn('[TaxonomyEmbeddings] Failed to load disk cache:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_saveCache() {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(CACHE_PATH), { recursive: true });
|
||||
fs.writeFileSync(CACHE_PATH, JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
contentHash: this.contentHash,
|
||||
categories: this.categories,
|
||||
themes: this.themes,
|
||||
colors: this.colors,
|
||||
}));
|
||||
this.logger.info(`[TaxonomyEmbeddings] Disk cache saved to ${CACHE_PATH}`);
|
||||
} catch (err) {
|
||||
this.logger.warn('[TaxonomyEmbeddings] Failed to save disk cache:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TaxonomyEmbeddings };
|
||||
|
||||
@@ -124,6 +124,17 @@ function isReady() {
|
||||
return initialized && taxonomyEmbeddings?.isReady();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background taxonomy change detection.
|
||||
* Call once after initialization, passing a function that returns { connection }.
|
||||
* @param {Function} getConnectionFn
|
||||
* @param {number} [intervalMs] - default 1 hour
|
||||
*/
|
||||
function startBackgroundCheck(getConnectionFn, intervalMs) {
|
||||
if (!initialized || !taxonomyEmbeddings) return;
|
||||
taxonomyEmbeddings.startBackgroundCheck(getConnectionFn, intervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build weighted product text for embedding.
|
||||
* Weights the product name heavily by repeating it, and truncates long descriptions
|
||||
@@ -362,6 +373,7 @@ module.exports = {
|
||||
initialize,
|
||||
isReady,
|
||||
getStatus,
|
||||
startBackgroundCheck,
|
||||
|
||||
// Embeddings (OpenAI)
|
||||
getProductEmbedding,
|
||||
|
||||
@@ -40,6 +40,9 @@ const Import = lazy(() => import('./pages/Import').then(module => ({ default: mo
|
||||
// Product editor
|
||||
const ProductEditor = lazy(() => import('./pages/ProductEditor'));
|
||||
|
||||
// Bulk edit
|
||||
const BulkEdit = lazy(() => import('./pages/BulkEdit'));
|
||||
|
||||
// 4. Chat archive - separate chunk
|
||||
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
|
||||
|
||||
@@ -198,6 +201,15 @@ function App() {
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Bulk edit */}
|
||||
<Route path="/bulk-edit" element={
|
||||
<Protected page="bulk_edit">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<BulkEdit />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Product import - separate chunk */}
|
||||
<Route path="/import" element={
|
||||
<Protected page="import">
|
||||
|
||||
252
inventory/src/components/ai/AiDescriptionCompare.tsx
Normal file
252
inventory/src/components/ai/AiDescriptionCompare.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* AiDescriptionCompare
|
||||
*
|
||||
* Shared side-by-side description editor for AI validation results.
|
||||
* Shows the current description next to the AI-suggested version,
|
||||
* both editable, with issues list and accept/dismiss actions.
|
||||
*
|
||||
* Layout uses a ResizeObserver to measure the right-side header+issues
|
||||
* area and mirrors that height as a spacer on the left so both
|
||||
* textareas start at the same vertical position. Textareas auto-resize
|
||||
* to fit their content; the parent container controls overflow.
|
||||
*
|
||||
* Used by:
|
||||
* - MultilineInput (inside a Popover, in the import validation table)
|
||||
* - ProductEditForm (inside a Dialog, in the product editor)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sparkles, AlertCircle, Check, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AiDescriptionCompareProps {
|
||||
currentValue: string;
|
||||
onCurrentChange: (value: string) => void;
|
||||
suggestion: string;
|
||||
issues: string[];
|
||||
onAccept: (editedSuggestion: string) => void;
|
||||
onDismiss: () => void;
|
||||
/** Called to re-roll (re-run) the AI validation */
|
||||
onRevalidate?: () => void;
|
||||
/** Whether re-validation is in progress */
|
||||
isRevalidating?: boolean;
|
||||
productName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AiDescriptionCompare({
|
||||
currentValue,
|
||||
onCurrentChange,
|
||||
suggestion,
|
||||
issues,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onRevalidate,
|
||||
isRevalidating = false,
|
||||
productName,
|
||||
className,
|
||||
}: AiDescriptionCompareProps) {
|
||||
const [editedSuggestion, setEditedSuggestion] = useState(suggestion);
|
||||
const [aiHeaderHeight, setAiHeaderHeight] = useState(0);
|
||||
const aiHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Reset edited suggestion when the suggestion prop changes
|
||||
useEffect(() => {
|
||||
setEditedSuggestion(suggestion);
|
||||
}, [suggestion]);
|
||||
|
||||
// Measure right-side header+issues area for left-side spacer alignment.
|
||||
// Wrapped in rAF because Radix portals mount asynchronously — the ref
|
||||
// is null on the first synchronous run.
|
||||
useEffect(() => {
|
||||
let observer: ResizeObserver | null = null;
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const el = aiHeaderRef.current;
|
||||
if (!el) return;
|
||||
observer = new ResizeObserver(([entry]) => {
|
||||
// Subtract 8px to compensate for the left column's py-2 top padding,
|
||||
// so both "Current Description" and "Suggested" labels align vertically.
|
||||
setAiHeaderHeight(Math.max(0, entry.contentRect.height - 8));
|
||||
});
|
||||
observer.observe(el);
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-resize both textareas to fit content, then equalize their heights
|
||||
// on desktop so tops and bottoms align exactly.
|
||||
const syncTextareaHeights = useCallback(() => {
|
||||
const main = mainTextareaRef.current;
|
||||
const suggestion = suggestionTextareaRef.current;
|
||||
if (!main && !suggestion) return;
|
||||
|
||||
// Reset to auto to measure natural content height
|
||||
if (main) main.style.height = "auto";
|
||||
if (suggestion) suggestion.style.height = "auto";
|
||||
|
||||
const mainH = main?.scrollHeight ?? 0;
|
||||
const suggestionH = suggestion?.scrollHeight ?? 0;
|
||||
|
||||
// On desktop (lg), equalize so both textareas are the same height
|
||||
const isDesktop = window.matchMedia("(min-width: 1024px)").matches;
|
||||
const targetH = isDesktop ? Math.max(mainH, suggestionH) : 0;
|
||||
|
||||
if (main) main.style.height = `${targetH || mainH}px`;
|
||||
if (suggestion) suggestion.style.height = `${targetH || suggestionH}px`;
|
||||
}, []);
|
||||
|
||||
// Sync heights on mount and when content changes.
|
||||
// Retry after a short delay to handle dialog/popover entry animations
|
||||
// where the DOM isn't fully laid out on the first frame.
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(syncTextareaHeights);
|
||||
const timer = setTimeout(syncTextareaHeights, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [currentValue, editedSuggestion, syncTextareaHeights]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col lg:flex-row items-stretch w-full", className)}>
|
||||
{/* Left: current description */}
|
||||
<div className="flex flex-col min-h-0 w-full lg:w-1/2">
|
||||
<div className="px-3 py-2 bg-accent flex flex-col flex-1 min-h-0">
|
||||
{/* Product name - shown inline on mobile */}
|
||||
{productName && (
|
||||
<div className="flex-shrink-0 flex flex-col lg:hidden px-1 mb-2">
|
||||
<div className="text-sm font-medium text-foreground mb-1">
|
||||
Editing description for:
|
||||
</div>
|
||||
<div className="text-md font-semibold text-foreground">
|
||||
{productName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Desktop spacer matching the right-side header+issues height */}
|
||||
{aiHeaderHeight > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 hidden lg:flex items-start"
|
||||
style={{ height: aiHeaderHeight }}
|
||||
>
|
||||
{productName && (
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium text-foreground px-1 mb-1">
|
||||
Editing description for:
|
||||
</div>
|
||||
<div className="text-md font-semibold text-foreground px-1">
|
||||
{productName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm mb-1 font-medium flex items-center gap-2 flex-shrink-0">
|
||||
Current Description:
|
||||
</div>
|
||||
<Textarea
|
||||
ref={mainTextareaRef}
|
||||
value={currentValue}
|
||||
onChange={(e) => {
|
||||
onCurrentChange(e.target.value);
|
||||
syncTextareaHeights();
|
||||
}}
|
||||
className="overflow-y-auto overscroll-contain text-sm resize-y bg-white min-h-[120px] max-h-[50vh]"
|
||||
/>
|
||||
{/* Footer spacer matching the action buttons height on the right */}
|
||||
<div className="h-[43px] flex-shrink-0 hidden lg:block" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: AI suggestion */}
|
||||
<div className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2">
|
||||
{/* Measured header + issues area (height mirrored as spacer on the left) */}
|
||||
<div ref={aiHeaderRef} className="flex-shrink-0">
|
||||
{/* Header */}
|
||||
<div className="w-full flex items-center justify-between px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||
AI Suggestion
|
||||
</span>
|
||||
<span className="text-xs text-purple-500 dark:text-purple-400">
|
||||
({issues.length} {issues.length === 1 ? "issue" : "issues"})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issues list */}
|
||||
{issues.length > 0 && (
|
||||
<div className="flex flex-col gap-1 px-3 pb-3">
|
||||
{issues.map((issue, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||
<span>{issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
|
||||
{/* Editable suggestion */}
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="text-sm text-purple-500 dark:text-purple-400 mb-1 font-medium flex-shrink-0">
|
||||
Suggested (editable):
|
||||
</div>
|
||||
<Textarea
|
||||
ref={suggestionTextareaRef}
|
||||
value={editedSuggestion}
|
||||
onChange={(e) => {
|
||||
setEditedSuggestion(e.target.value);
|
||||
syncTextareaHeights();
|
||||
}}
|
||||
className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y min-h-[120px] max-h-[50vh]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||
onClick={() => onAccept(editedSuggestion)}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Replace With Suggestion
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Ignore
|
||||
</Button>
|
||||
{onRevalidate && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-3 text-xs text-purple-500 hover:text-purple-700 dark:text-purple-400"
|
||||
disabled={isRevalidating}
|
||||
onClick={onRevalidate}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${isRevalidating ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
inventory/src/components/analytics/AgingSellThrough.tsx
Normal file
143
inventory/src/components/analytics/AgingSellThrough.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface AgingCohort {
|
||||
cohort: string;
|
||||
productCount: number;
|
||||
avgSellThrough: number;
|
||||
stockCost: number;
|
||||
revenue: number;
|
||||
unitsSold: number;
|
||||
}
|
||||
|
||||
function getSellThroughColor(rate: number): string {
|
||||
if (rate >= 30) return METRIC_COLORS.revenue;
|
||||
if (rate >= 15) return METRIC_COLORS.orders;
|
||||
if (rate >= 5) return METRIC_COLORS.comparison;
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
export function AgingSellThrough() {
|
||||
const { data, isLoading, isError } = useQuery<AgingCohort[]>({
|
||||
queryKey: ['aging-sell-through'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/aging`);
|
||||
if (!response.ok) throw new Error('Failed to fetch aging data');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Aging & Sell-Through</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load aging data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Aging & Sell-Through</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading aging data...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sell-Through Rate by Age</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Avg 30-day sell-through % for products by age since first received
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="cohort" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as AgingCohort;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">Age: {d.cohort}</p>
|
||||
<p>Sell-through: <span className="font-medium">{d.avgSellThrough}%</span></p>
|
||||
<p>{d.productCount.toLocaleString()} products</p>
|
||||
<p>Stock value: {formatCurrency(d.stockCost)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="avgSellThrough" name="Sell-Through %" radius={[4, 4, 0, 0]}>
|
||||
{data.map((entry, i) => (
|
||||
<Cell key={i} fill={getSellThroughColor(entry.avgSellThrough)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Capital Tied Up by Age</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stock investment distribution across product age cohorts
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="cohort" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={formatCurrency} tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as AgingCohort;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">Age: {d.cohort}</p>
|
||||
<p>Stock cost: {formatCurrency(d.stockCost)}</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
|
||||
<p>{d.productCount.toLocaleString()} products</p>
|
||||
<p>{d.unitsSold.toLocaleString()} units sold (30d)</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="stockCost" name="Stock Investment" fill={METRIC_COLORS.aov} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
inventory/src/components/analytics/CapitalEfficiency.tsx
Normal file
201
inventory/src/components/analytics/CapitalEfficiency.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
ZAxis,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface BrandData {
|
||||
brand: string;
|
||||
productCount: number;
|
||||
stockCost: number;
|
||||
profit30d: number;
|
||||
revenue30d: number;
|
||||
gmroi: number;
|
||||
}
|
||||
|
||||
interface EfficiencyData {
|
||||
brands: BrandData[];
|
||||
}
|
||||
|
||||
function getGmroiColor(gmroi: number): string {
|
||||
if (gmroi >= 3) return METRIC_COLORS.revenue; // emerald — strong
|
||||
if (gmroi >= 1) return METRIC_COLORS.comparison; // amber — acceptable
|
||||
return '#ef4444'; // red — poor
|
||||
}
|
||||
|
||||
type GmroiView = 'top' | 'bottom';
|
||||
|
||||
export function CapitalEfficiency() {
|
||||
const [gmroiView, setGmroiView] = useState<GmroiView>('top');
|
||||
const { data, isLoading, isError } = useQuery<EfficiencyData>({
|
||||
queryKey: ['capital-efficiency'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/efficiency`);
|
||||
if (!response.ok) throw new Error('Failed to fetch capital efficiency');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Capital Efficiency</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load efficiency data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Capital Efficiency</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading efficiency...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Top or bottom 15 by GMROI for bar chart
|
||||
const sortedGmroi = gmroiView === 'top'
|
||||
? [...data.brands].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15)
|
||||
: [...data.brands].sort((a, b) => a.gmroi - b.gmroi).slice(0, 15);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>GMROI by Brand</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Annualized gross margin return on investment (top 30 brands by stock value)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(['top', 'bottom'] as GmroiView[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setGmroiView(v)}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
gmroiView === v
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
{v === 'top' ? 'Best' : 'Worst'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={sortedGmroi} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="brand"
|
||||
width={140}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as BrandData;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">{d.brand}</p>
|
||||
<p>GMROI: <span className="font-medium">{d.gmroi.toFixed(2)}</span></p>
|
||||
<p>Stock Investment: {formatCurrency(d.stockCost)}</p>
|
||||
<p>Profit (30d): {formatCurrency(d.profit30d)}</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
|
||||
<p>{d.productCount} products</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine x={3} stroke="#9ca3af" strokeDasharray="3 3" label={{ value: '3.0', position: 'top', fontSize: 10 }} />
|
||||
<Bar dataKey="gmroi" name="GMROI" radius={[0, 4, 4, 0]}>
|
||||
{sortedGmroi.map((entry, i) => (
|
||||
<Cell key={i} fill={getGmroiColor(entry.gmroi)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Investment vs Profit by Brand</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bubble size = product count. Ideal: high profit, low stock cost.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<ScatterChart>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="stockCost"
|
||||
name="Stock Investment"
|
||||
tickFormatter={formatCurrency}
|
||||
tick={{ fontSize: 11 }}
|
||||
type="number"
|
||||
label={{ value: 'Stock Investment', position: 'insideBottom', offset: -5, fontSize: 12, fill: '#888' }}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="profit30d"
|
||||
name="Profit (30d)"
|
||||
tickFormatter={formatCurrency}
|
||||
tick={{ fontSize: 11 }}
|
||||
type="number"
|
||||
label={{ value: 'Profit (30d)', angle: -90, position: 'insideLeft', offset: 10, fontSize: 12, fill: '#888' }}
|
||||
/>
|
||||
<ZAxis dataKey="productCount" range={[40, 400]} name="Products" />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as BrandData;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">{d.brand}</p>
|
||||
<p>Stock Investment: {formatCurrency(d.stockCost)}</p>
|
||||
<p>Profit (30d): {formatCurrency(d.profit30d)}</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
|
||||
<p>{d.productCount} products</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Scatter data={data.brands} fill={METRIC_COLORS.orders} fillOpacity={0.6} />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, PieChart, Pie, Cell, Legend } from 'recharts';
|
||||
import config from '../../config';
|
||||
|
||||
interface CategoryData {
|
||||
performance: {
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
revenue: number;
|
||||
profit: number;
|
||||
growth: number;
|
||||
productCount: number;
|
||||
}[];
|
||||
distribution: {
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
value: number;
|
||||
}[];
|
||||
trends: {
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
month: string;
|
||||
sales: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
const COLORS = ['#4ade80', '#60a5fa', '#f87171', '#fbbf24', '#a78bfa', '#f472b6'];
|
||||
|
||||
export function CategoryPerformance() {
|
||||
const { data, isLoading } = useQuery<CategoryData>({
|
||||
queryKey: ['category-performance'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/categories`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch category performance');
|
||||
}
|
||||
const rawData = await response.json();
|
||||
return {
|
||||
performance: rawData.performance.map((item: any) => ({
|
||||
category: item.category || '',
|
||||
categoryPath: item.categoryPath || item.categorypath || item.category || '',
|
||||
revenue: Number(item.revenue) || 0,
|
||||
profit: Number(item.profit) || 0,
|
||||
growth: Number(item.growth) || 0,
|
||||
productCount: Number(item.productCount) || Number(item.productcount) || 0
|
||||
})),
|
||||
distribution: rawData.distribution.map((item: any) => ({
|
||||
category: item.category || '',
|
||||
categoryPath: item.categoryPath || item.categorypath || item.category || '',
|
||||
value: Number(item.value) || 0
|
||||
})),
|
||||
trends: rawData.trends.map((item: any) => ({
|
||||
category: item.category || '',
|
||||
categoryPath: item.categoryPath || item.categorypath || item.category || '',
|
||||
month: item.month || '',
|
||||
sales: Number(item.sales) || 0
|
||||
}))
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <div>Loading category performance...</div>;
|
||||
}
|
||||
|
||||
const formatGrowth = (growth: number) => {
|
||||
const value = growth >= 0 ? `+${growth.toFixed(1)}%` : `${growth.toFixed(1)}%`;
|
||||
const color = growth >= 0 ? 'text-green-500' : 'text-red-500';
|
||||
return <span className={color}>{value}</span>;
|
||||
};
|
||||
|
||||
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category Revenue Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.distribution}
|
||||
dataKey="value"
|
||||
nameKey="categoryPath"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
label={({ categoryPath }) => getShortCategoryName(categoryPath)}
|
||||
>
|
||||
{data.distribution.map((entry, index) => (
|
||||
<Cell
|
||||
key={`${entry.category}-${entry.value}-${index}`}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number, _: string, props: any) => [
|
||||
`$${value.toLocaleString()}`,
|
||||
<div key="tooltip">
|
||||
<div className="font-medium">Category Path:</div>
|
||||
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
|
||||
<div className="mt-1">Revenue</div>
|
||||
</div>
|
||||
]}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value) => getShortCategoryName(value)}
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category Growth Rates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data.performance}>
|
||||
<XAxis
|
||||
dataKey="categoryPath"
|
||||
tick={({ x, y, payload }) => (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
dy={16}
|
||||
textAnchor="end"
|
||||
fill="#888888"
|
||||
transform="rotate(-35)"
|
||||
>
|
||||
{getShortCategoryName(payload.value)}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||
<Tooltip
|
||||
formatter={(value: number, _: string, props: any) => [
|
||||
`${value.toFixed(1)}%`,
|
||||
<div key="tooltip">
|
||||
<div className="font-medium">Category Path:</div>
|
||||
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
|
||||
<div className="mt-1">Growth Rate</div>
|
||||
</div>
|
||||
]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="growth"
|
||||
fill="#4ade80"
|
||||
name="Growth Rate"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category Performance Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{data.performance.map((category) => (
|
||||
<div key={`${category.category}-${category.revenue}`} className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{getShortCategoryName(category.categoryPath)}</p>
|
||||
<p className="text-xs text-muted-foreground">{category.categoryPath}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{category.productCount} products
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 text-right space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
${category.revenue.toLocaleString()} revenue
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
${category.profit.toLocaleString()} profit
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Growth: {formatGrowth(category.growth)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
inventory/src/components/analytics/DiscountImpact.tsx
Normal file
159
inventory/src/components/analytics/DiscountImpact.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface DiscountRow {
|
||||
abcClass: string;
|
||||
discountBucket: string;
|
||||
productCount: number;
|
||||
avgSellThrough: number;
|
||||
revenue: number;
|
||||
discountAmount: number;
|
||||
profit: number;
|
||||
}
|
||||
|
||||
const CLASS_COLORS: Record<string, string> = {
|
||||
A: METRIC_COLORS.revenue,
|
||||
B: METRIC_COLORS.orders,
|
||||
C: METRIC_COLORS.comparison,
|
||||
};
|
||||
|
||||
export function DiscountImpact() {
|
||||
const { data, isLoading, isError } = useQuery<DiscountRow[]>({
|
||||
queryKey: ['discount-impact'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/discounts`);
|
||||
if (!response.ok) throw new Error('Failed to fetch discount data');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Discount Impact</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load discount data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Discount Impact</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading discount data...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Pivot: for each discount bucket, show avg sell-through by ABC class
|
||||
const buckets = ['No Discount', '1-10%', '11-20%', '21-30%', '30%+'];
|
||||
const chartData = buckets.map(bucket => {
|
||||
const row: Record<string, string | number> = { bucket };
|
||||
['A', 'B', 'C'].forEach(cls => {
|
||||
const match = data.find(d => d.discountBucket === bucket && d.abcClass === cls);
|
||||
row[`Class ${cls}`] = match?.avgSellThrough || 0;
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
// Summary by ABC class
|
||||
const classSummary = ['A', 'B', 'C'].map(cls => {
|
||||
const rows = data.filter(d => d.abcClass === cls);
|
||||
return {
|
||||
abcClass: cls,
|
||||
totalProducts: rows.reduce((s, r) => s + r.productCount, 0),
|
||||
totalDiscounts: rows.reduce((s, r) => s + r.discountAmount, 0),
|
||||
totalRevenue: rows.reduce((s, r) => s + r.revenue, 0),
|
||||
totalProfit: rows.reduce((s, r) => s + r.profit, 0),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sell-Through by Discount Level</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Avg 30-day sell-through % at each discount bracket, by ABC class
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="bucket" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(value: number) => [`${value}%`]} />
|
||||
<Legend />
|
||||
<Bar dataKey="Class A" fill={CLASS_COLORS.A} radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="Class B" fill={CLASS_COLORS.B} radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="Class C" fill={CLASS_COLORS.C} radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Discount Leakage by Class</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How much discount is given relative to revenue per ABC class
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-2 text-left font-medium">Class</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Products</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Revenue</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Discounts</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Disc %</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Profit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classSummary.map((row) => (
|
||||
<tr key={row.abcClass} className="border-b">
|
||||
<td className="px-4 py-2 font-medium">Class {row.abcClass}</td>
|
||||
<td className="px-4 py-2 text-right">{row.totalProducts.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(row.totalRevenue)}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(row.totalDiscounts)}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{row.totalRevenue > 0
|
||||
? ((row.totalDiscounts / (row.totalRevenue + row.totalDiscounts)) * 100).toFixed(1)
|
||||
: '0'}%
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(row.totalProfit)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
inventory/src/components/analytics/GrowthMomentum.tsx
Normal file
217
inventory/src/components/analytics/GrowthMomentum.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { TrendingUp, TrendingDown, Plus, Archive } from 'lucide-react';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface GrowthRow {
|
||||
abcClass: string;
|
||||
growthBucket: string;
|
||||
productCount: number;
|
||||
revenue: number;
|
||||
stockCost: number;
|
||||
}
|
||||
|
||||
interface GrowthSummary {
|
||||
comparableCount: number;
|
||||
growingCount: number;
|
||||
decliningCount: number;
|
||||
weightedAvgGrowth: number;
|
||||
medianGrowth: number;
|
||||
}
|
||||
|
||||
interface CatalogTurnover {
|
||||
newProducts: number;
|
||||
newProductRevenue: number;
|
||||
discontinued: number;
|
||||
discontinuedStockValue: number;
|
||||
}
|
||||
|
||||
interface GrowthData {
|
||||
byClass: GrowthRow[];
|
||||
summary: GrowthSummary;
|
||||
turnover: CatalogTurnover;
|
||||
}
|
||||
|
||||
const GROWTH_COLORS: Record<string, string> = {
|
||||
'Strong Growth (>50%)': METRIC_COLORS.revenue,
|
||||
'Growing (0-50%)': '#34d399',
|
||||
'Declining (0-50%)': METRIC_COLORS.comparison,
|
||||
'Sharp Decline (>50%)': '#ef4444',
|
||||
};
|
||||
|
||||
export function GrowthMomentum() {
|
||||
const { data, isLoading, isError } = useQuery<GrowthData>({
|
||||
queryKey: ['growth-momentum'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/growth`);
|
||||
if (!response.ok) throw new Error('Failed to fetch growth data');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>YoY Growth Momentum</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load growth data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>YoY Growth Momentum</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading growth data...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, turnover } = data;
|
||||
const growingPct = summary.comparableCount > 0
|
||||
? ((summary.growingCount / summary.comparableCount) * 100).toFixed(0)
|
||||
: '0';
|
||||
|
||||
// Pivot: for each ABC class, show product counts by growth bucket
|
||||
const classes = ['A', 'B', 'C'];
|
||||
const buckets = ['Strong Growth (>50%)', 'Growing (0-50%)', 'Declining (0-50%)', 'Sharp Decline (>50%)'];
|
||||
const chartData = classes.map(cls => {
|
||||
const row: Record<string, string | number> = { abcClass: `Class ${cls}` };
|
||||
buckets.forEach(bucket => {
|
||||
const match = data.byClass.find(d => d.abcClass === cls && d.growthBucket === bucket);
|
||||
row[bucket] = match?.productCount || 0;
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Row 1: Comparable growth metrics */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-green-500/10">
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Comparable Growing</p>
|
||||
<p className="text-xl font-bold">{growingPct}%</p>
|
||||
<p className="text-xs text-muted-foreground">{summary.growingCount.toLocaleString()} of {summary.comparableCount.toLocaleString()} products</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-red-500/10">
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Comparable Declining</p>
|
||||
<p className="text-xl font-bold">{summary.decliningCount.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">products with lower YoY sales</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">Weighted Avg Growth</p>
|
||||
<p className={`text-2xl font-bold ${summary.weightedAvgGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{summary.weightedAvgGrowth > 0 ? '+' : ''}{summary.weightedAvgGrowth}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">revenue-weighted</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">Median YoY Growth</p>
|
||||
<p className={`text-2xl font-bold ${summary.medianGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{summary.medianGrowth > 0 ? '+' : ''}{summary.medianGrowth}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">typical product growth</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Catalog turnover */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-blue-500/10">
|
||||
<Plus className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">New Products (<1yr)</p>
|
||||
<p className="text-xl font-bold">{turnover.newProducts.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatCurrency(turnover.newProductRevenue)} revenue (30d)</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-amber-500/10">
|
||||
<Archive className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Discontinued</p>
|
||||
<p className="text-xl font-bold">{turnover.discontinued.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{turnover.discontinuedStockValue > 0
|
||||
? `${formatCurrency(turnover.discontinuedStockValue)} still in stock`
|
||||
: 'no remaining stock'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Chart: comparable products only */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Comparable Growth by ABC Class</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Products selling in both this and last year's period — excludes new launches and discontinued
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="abcClass" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} label={{ value: 'Products', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }} />
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{buckets.map(bucket => (
|
||||
<Bar
|
||||
key={bucket}
|
||||
dataKey={bucket}
|
||||
stackId="growth"
|
||||
fill={GROWTH_COLORS[bucket]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
inventory/src/components/analytics/InventoryFlow.tsx
Normal file
186
inventory/src/components/analytics/InventoryFlow.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
import { ArrowDownToLine, ArrowUpFromLine, TrendingUp } from 'lucide-react';
|
||||
|
||||
interface FlowPoint {
|
||||
date: string;
|
||||
unitsReceived: number;
|
||||
costReceived: number;
|
||||
unitsSold: number;
|
||||
cogsSold: number;
|
||||
}
|
||||
|
||||
type Period = 30 | 90;
|
||||
|
||||
function formatDate(dateStr: string, period: Period): string {
|
||||
const d = new Date(dateStr);
|
||||
if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function InventoryFlow() {
|
||||
const [period, setPeriod] = useState<Period>(30);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<FlowPoint[]>({
|
||||
queryKey: ['inventory-flow', period],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/flow?period=${period}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch inventory flow');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
const totals = useMemo(() => {
|
||||
if (!data) return { received: 0, sold: 0, net: 0 };
|
||||
const received = data.reduce((s, d) => s + d.costReceived, 0);
|
||||
const sold = data.reduce((s, d) => s + d.cogsSold, 0);
|
||||
return { received, sold, net: received - sold };
|
||||
}, [data]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
netFlow: d.costReceived - d.cogsSold,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Inventory Flow: Receiving vs Selling</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Daily cost of goods received vs cost of goods sold
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{([30, 90] as Period[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
period === p
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
{`${p}D`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isError ? (
|
||||
<div className="h-[400px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load flow data</p>
|
||||
</div>
|
||||
) : isLoading || !data ? (
|
||||
<div className="h-[400px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading flow data...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Summary stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div className="rounded-full p-2 bg-green-500/10">
|
||||
<ArrowDownToLine className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Received</p>
|
||||
<p className="text-lg font-bold">{formatCurrency(totals.received)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div className="rounded-full p-2 bg-blue-500/10">
|
||||
<ArrowUpFromLine className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Sold (COGS)</p>
|
||||
<p className="text-lg font-bold">{formatCurrency(totals.sold)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div className={`rounded-full p-2 ${totals.net >= 0 ? 'bg-amber-500/10' : 'bg-green-500/10'}`}>
|
||||
<TrendingUp className={`h-4 w-4 ${totals.net >= 0 ? 'text-amber-500' : 'text-green-500'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Net Change</p>
|
||||
<p className={`text-lg font-bold ${totals.net >= 0 ? 'text-amber-600' : 'text-green-600'}`}>
|
||||
{totals.net >= 0 ? '+' : ''}{formatCurrency(totals.net)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totals.net >= 0 ? 'inventory growing' : 'inventory shrinking'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ComposedChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v) => formatDate(v, period)}
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={period === 90 ? 6 : 2}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatCurrency}
|
||||
tick={{ fontSize: 12 }}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(v) => new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
formatter={(value: number, name: string) => [formatCurrency(value), name]}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="costReceived"
|
||||
fill={METRIC_COLORS.revenue}
|
||||
name="Received (Cost)"
|
||||
radius={[2, 2, 0, 0]}
|
||||
opacity={0.7}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="cogsSold"
|
||||
fill={METRIC_COLORS.orders}
|
||||
name="Sold (COGS)"
|
||||
radius={[2, 2, 0, 0]}
|
||||
opacity={0.7}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="netFlow"
|
||||
stroke={METRIC_COLORS.comparison}
|
||||
name="Net Flow"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
131
inventory/src/components/analytics/InventoryTrends.tsx
Normal file
131
inventory/src/components/analytics/InventoryTrends.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
ComposedChart,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
|
||||
interface TrendPoint {
|
||||
date: string;
|
||||
stockoutCount: number;
|
||||
unitsSold: number;
|
||||
}
|
||||
|
||||
type Period = 30 | 90 | 365;
|
||||
|
||||
function formatDate(dateStr: string, period: Period): string {
|
||||
const d = new Date(dateStr);
|
||||
if (period === 365) return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||
if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function InventoryTrends() {
|
||||
const [period, setPeriod] = useState<Period>(90);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<TrendPoint[]>({
|
||||
queryKey: ['inventory-trends', period],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/inventory-trends?period=${period}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch inventory trends');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Daily Sales Activity & Stockouts</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Units sold per day with stockout product count overlay
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{([30, 90, 365] as Period[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
period === p
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
{p === 365 ? '1Y' : `${p}D`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isError ? (
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load trends data</p>
|
||||
</div>
|
||||
) : isLoading || !data ? (
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading trends...</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<ComposedChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v) => formatDate(v, period)}
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={period === 365 ? 29 : period === 90 ? 6 : 2}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fontSize: 12 }}
|
||||
width={60}
|
||||
label={{ value: 'Units Sold', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 12 }}
|
||||
width={60}
|
||||
label={{ value: 'Stockouts', angle: 90, position: 'insideRight', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(v) => new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
|
||||
/>
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="unitsSold"
|
||||
fill={METRIC_COLORS.orders}
|
||||
name="Units Sold"
|
||||
radius={[2, 2, 0, 0]}
|
||||
opacity={0.7}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="stockoutCount"
|
||||
stroke="#ef4444"
|
||||
name="Products Stocked Out"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
159
inventory/src/components/analytics/InventoryValueTrend.tsx
Normal file
159
inventory/src/components/analytics/InventoryValueTrend.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
ComposedChart,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface ValuePoint {
|
||||
date: string;
|
||||
totalValue: number;
|
||||
productCount: number;
|
||||
}
|
||||
|
||||
type Period = 30 | 90 | 365;
|
||||
|
||||
function formatDate(dateStr: string, period: Period): string {
|
||||
const d = new Date(dateStr);
|
||||
if (period === 365) return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||
if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function InventoryValueTrend() {
|
||||
const [period, setPeriod] = useState<Period>(90);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<ValuePoint[]>({
|
||||
queryKey: ['inventory-value', period],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/inventory-value?period=${period}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch inventory value');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
const latest = data?.[data.length - 1];
|
||||
const earliest = data?.[0];
|
||||
const change = latest && earliest ? latest.totalValue - earliest.totalValue : 0;
|
||||
const changePct = earliest && earliest.totalValue > 0
|
||||
? ((change / earliest.totalValue) * 100).toFixed(1)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Inventory Value Over Time</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Total stock investment (cost) with product count overlay
|
||||
{latest && (
|
||||
<span className="ml-2">
|
||||
— Current: <span className="font-medium">{formatCurrency(latest.totalValue)}</span>
|
||||
{' '}
|
||||
<span className={change >= 0 ? 'text-green-600' : 'text-red-600'}>
|
||||
({change >= 0 ? '+' : ''}{changePct}%)
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{([30, 90, 365] as Period[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
period === p
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
{p === 365 ? '1Y' : `${p}D`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isError ? (
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load inventory value data</p>
|
||||
</div>
|
||||
) : isLoading || !data ? (
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading inventory value...</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<ComposedChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="valueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={METRIC_COLORS.revenue} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={METRIC_COLORS.revenue} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v) => formatDate(v, period)}
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={period === 365 ? 29 : period === 90 ? 6 : 2}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tickFormatter={formatCurrency}
|
||||
tick={{ fontSize: 12 }}
|
||||
width={70}
|
||||
label={{ value: 'Stock Value', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 12 }}
|
||||
width={60}
|
||||
label={{ value: 'Products', angle: 90, position: 'insideRight', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(v) => new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
formatter={(value: number, name: string) => [
|
||||
name === 'Stock Value' ? formatCurrency(value) : value.toLocaleString(),
|
||||
name,
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="totalValue"
|
||||
fill="url(#valueGradient)"
|
||||
stroke={METRIC_COLORS.revenue}
|
||||
strokeWidth={2}
|
||||
name="Stock Value"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="productCount"
|
||||
stroke={METRIC_COLORS.orders}
|
||||
name="Products in Stock"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
226
inventory/src/components/analytics/PortfolioAnalysis.tsx
Normal file
226
inventory/src/components/analytics/PortfolioAnalysis.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
CartesianGrid,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { PackageX, Archive } from 'lucide-react';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface AbcItem {
|
||||
abcClass: string;
|
||||
productCount: number;
|
||||
revenue: number;
|
||||
stockCost: number;
|
||||
profit: number;
|
||||
unitsSold: number;
|
||||
}
|
||||
|
||||
interface StockIssues {
|
||||
deadStockCount: number;
|
||||
deadStockCost: number;
|
||||
deadStockRetail: number;
|
||||
overstockCount: number;
|
||||
overstockCost: number;
|
||||
overstockRetail: number;
|
||||
}
|
||||
|
||||
interface PortfolioData {
|
||||
abcBreakdown: AbcItem[];
|
||||
stockIssues: StockIssues;
|
||||
}
|
||||
|
||||
export function PortfolioAnalysis() {
|
||||
const { data, isLoading, isError } = useQuery<PortfolioData>({
|
||||
queryKey: ['portfolio-analysis'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/portfolio`);
|
||||
if (!response.ok) throw new Error('Failed to fetch portfolio analysis');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Portfolio & ABC Analysis</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load portfolio data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Portfolio & ABC Analysis</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading portfolio...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Include all classes — rename N/A to "Unclassified"
|
||||
const allClasses = data.abcBreakdown.map(r => ({
|
||||
...r,
|
||||
abcClass: r.abcClass === 'N/A' ? 'Unclassified' : r.abcClass,
|
||||
}));
|
||||
const totalRevenue = allClasses.reduce((s, r) => s + r.revenue, 0);
|
||||
const totalStockCost = allClasses.reduce((s, r) => s + r.stockCost, 0);
|
||||
const totalProducts = allClasses.reduce((s, r) => s + r.productCount, 0);
|
||||
|
||||
// Compute percentage data for the grouped bar chart
|
||||
const chartData = allClasses.map(r => ({
|
||||
abcClass: r.abcClass === 'Unclassified' ? 'Unclassified' : `Class ${r.abcClass}`,
|
||||
'% of Products': totalProducts > 0 ? Number(((r.productCount / totalProducts) * 100).toFixed(1)) : 0,
|
||||
'% of Revenue': totalRevenue > 0 ? Number(((r.revenue / totalRevenue) * 100).toFixed(1)) : 0,
|
||||
'% of Stock Investment': totalStockCost > 0 ? Number(((r.stockCost / totalStockCost) * 100).toFixed(1)) : 0,
|
||||
}));
|
||||
|
||||
const abcOnly = allClasses.filter(r => ['A', 'B', 'C'].includes(r.abcClass));
|
||||
const abcRevenue = abcOnly.reduce((s, r) => s + r.revenue, 0);
|
||||
const aClass = allClasses.find(r => r.abcClass === 'A');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ABC Class Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="abcClass" tick={{ fontSize: 12 }} />
|
||||
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 12 }} />
|
||||
<Tooltip formatter={(value: number) => [`${value}%`]} />
|
||||
<Legend />
|
||||
<Bar dataKey="% of Products" fill={METRIC_COLORS.orders} radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="% of Revenue" fill={METRIC_COLORS.revenue} radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="% of Stock Investment" fill={METRIC_COLORS.aov} radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 grid-rows-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="rounded-full p-2 bg-green-500/10">
|
||||
<TrendingUpIcon className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">A-Class Products</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{aClass ? aClass.productCount.toLocaleString() : 0} products
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold">
|
||||
{abcRevenue > 0 && aClass ? ((aClass.revenue / abcRevenue) * 100).toFixed(0) : 0}% of classified revenue
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatCurrency(aClass?.revenue || 0)} (30d)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="rounded-full p-2 bg-amber-500/10">
|
||||
<Archive className="h-5 w-5 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Dead Stock</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.stockIssues.deadStockCount.toLocaleString()} products
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold text-amber-500">
|
||||
{formatCurrency(data.stockIssues.deadStockCost)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">capital tied up</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="rounded-full p-2 bg-red-500/10">
|
||||
<PackageX className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Overstock</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.stockIssues.overstockCount.toLocaleString()} products
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold text-red-500">
|
||||
{formatCurrency(data.stockIssues.overstockCost)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">excess investment</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ABC breakdown table */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-2 text-left font-medium">Class</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Products</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Revenue (30d)</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Profit (30d)</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Stock Cost</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Units Sold (30d)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allClasses.map((row) => (
|
||||
<tr key={row.abcClass} className="border-b">
|
||||
<td className="px-4 py-2 font-medium">{row.abcClass === 'Unclassified' ? 'Unclassified' : `Class ${row.abcClass}`}</td>
|
||||
<td className="px-4 py-2 text-right">{row.productCount.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(row.revenue)}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(row.profit)}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(row.stockCost)}</td>
|
||||
<td className="px-4 py-2 text-right">{row.unitsSold.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendingUpIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
|
||||
<polyline points="16 7 22 7 22 13" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ResponsiveContainer, ScatterChart, Scatter, XAxis, YAxis, Tooltip, ZAxis, LineChart, Line } from 'recharts';
|
||||
import config from '../../config';
|
||||
|
||||
interface PriceData {
|
||||
pricePoints: {
|
||||
price: number;
|
||||
salesVolume: number;
|
||||
revenue: number;
|
||||
category: string;
|
||||
}[];
|
||||
elasticity: {
|
||||
date: string;
|
||||
price: number;
|
||||
demand: number;
|
||||
}[];
|
||||
recommendations: {
|
||||
product: string;
|
||||
currentPrice: number;
|
||||
recommendedPrice: number;
|
||||
potentialRevenue: number;
|
||||
confidence: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function PriceAnalysis() {
|
||||
const { data, isLoading, error } = useQuery<PriceData>({
|
||||
queryKey: ['price-analysis'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/pricing`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
const rawData = await response.json();
|
||||
|
||||
if (!rawData || !rawData.pricePoints) {
|
||||
return {
|
||||
pricePoints: [],
|
||||
elasticity: [],
|
||||
recommendations: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pricePoints: (rawData.pricePoints || []).map((item: any) => ({
|
||||
price: Number(item.price) || 0,
|
||||
salesVolume: Number(item.salesVolume || item.salesvolume) || 0,
|
||||
revenue: Number(item.revenue) || 0,
|
||||
category: item.category || ''
|
||||
})),
|
||||
elasticity: (rawData.elasticity || []).map((item: any) => ({
|
||||
date: item.date || '',
|
||||
price: Number(item.price) || 0,
|
||||
demand: Number(item.demand) || 0
|
||||
})),
|
||||
recommendations: (rawData.recommendations || []).map((item: any) => ({
|
||||
product: item.product || '',
|
||||
currentPrice: Number(item.currentPrice || item.currentprice) || 0,
|
||||
recommendedPrice: Number(item.recommendedPrice || item.recommendedprice) || 0,
|
||||
potentialRevenue: Number(item.potentialRevenue || item.potentialrevenue) || 0,
|
||||
confidence: Number(item.confidence) || 0
|
||||
}))
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error fetching price data:', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
retry: 1
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading price analysis...</div>;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Price Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-red-500">
|
||||
Unable to load price analysis. The price metrics may need to be set up in the database.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Early return if no data to display
|
||||
if (
|
||||
data.pricePoints.length === 0 &&
|
||||
data.elasticity.length === 0 &&
|
||||
data.recommendations.length === 0
|
||||
) {
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Price Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
No price data available. This may be because the price metrics haven't been calculated yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Price Point Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart>
|
||||
<XAxis
|
||||
dataKey="price"
|
||||
name="Price"
|
||||
tickFormatter={(value) => `$${value}`}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="salesVolume"
|
||||
name="Sales Volume"
|
||||
/>
|
||||
<ZAxis
|
||||
dataKey="revenue"
|
||||
range={[50, 400]}
|
||||
name="Revenue"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Price') return [`$${value}`, name];
|
||||
if (name === 'Sales Volume') return [value.toLocaleString(), name];
|
||||
if (name === 'Revenue') return [`$${value.toLocaleString()}`, name];
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Scatter
|
||||
data={data.pricePoints}
|
||||
fill="#a78bfa"
|
||||
name="Products"
|
||||
/>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Price Elasticity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data.elasticity}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||
/>
|
||||
<YAxis yAxisId="left" orientation="left" stroke="#a78bfa" />
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
stroke="#4ade80"
|
||||
tickFormatter={(value) => `$${value}`}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(label) => new Date(label).toLocaleDateString()}
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Price') return [`$${value}`, name];
|
||||
return [value.toLocaleString(), name];
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="demand"
|
||||
stroke="#a78bfa"
|
||||
name="Demand"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="price"
|
||||
stroke="#4ade80"
|
||||
name="Price"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Price Optimization Recommendations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{data.recommendations.map((item) => (
|
||||
<div key={`${item.product}-${item.currentPrice}`} className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{item.product}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current Price: ${item.currentPrice.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 text-right space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
Recommended: ${item.recommendedPrice.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Potential Revenue: ${item.potentialRevenue.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Confidence: {item.confidence}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts';
|
||||
import config from '../../config';
|
||||
|
||||
interface ProfitData {
|
||||
byCategory: {
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
profitMargin: number;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
}[];
|
||||
overTime: {
|
||||
date: string;
|
||||
profitMargin: number;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
}[];
|
||||
topProducts: {
|
||||
product: string;
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
profitMargin: number;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function ProfitAnalysis() {
|
||||
const { data, isLoading } = useQuery<ProfitData>({
|
||||
queryKey: ['profit-analysis'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/profit`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch profit analysis');
|
||||
}
|
||||
const rawData = await response.json();
|
||||
return {
|
||||
byCategory: rawData.byCategory.map((item: any) => ({
|
||||
category: item.category || '',
|
||||
categoryPath: item.categorypath || item.category || '',
|
||||
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
|
||||
revenue: Number(item.revenue) || 0,
|
||||
cost: Number(item.cost) || 0
|
||||
})),
|
||||
overTime: rawData.overTime.map((item: any) => ({
|
||||
date: item.date || '',
|
||||
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
|
||||
revenue: Number(item.revenue) || 0,
|
||||
cost: Number(item.cost) || 0
|
||||
})),
|
||||
topProducts: rawData.topProducts.map((item: any) => ({
|
||||
product: item.product || '',
|
||||
category: item.category || '',
|
||||
categoryPath: item.categorypath || item.category || '',
|
||||
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
|
||||
revenue: Number(item.revenue) || 0,
|
||||
cost: Number(item.cost) || 0
|
||||
}))
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <div>Loading profit analysis...</div>;
|
||||
}
|
||||
|
||||
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profit Margins by Category</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data.byCategory}>
|
||||
<XAxis
|
||||
dataKey="categoryPath"
|
||||
tick={({ x, y, payload }) => (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
dy={16}
|
||||
textAnchor="end"
|
||||
fill="#888888"
|
||||
transform="rotate(-35)"
|
||||
>
|
||||
{getShortCategoryName(payload.value)}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||
<Tooltip
|
||||
formatter={(value: number, _: string, props: any) => [
|
||||
`${value.toFixed(1)}%`,
|
||||
<div key="tooltip">
|
||||
<div className="font-medium">Category Path:</div>
|
||||
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
|
||||
<div className="mt-1">Profit Margin</div>
|
||||
</div>
|
||||
]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="profitMargin"
|
||||
fill="#4ade80"
|
||||
name="Profit Margin"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profit Margin Trend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data.overTime}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||
<Tooltip
|
||||
labelFormatter={(label) => new Date(label).toLocaleDateString()}
|
||||
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="profitMargin"
|
||||
stroke="#4ade80"
|
||||
name="Profit Margin"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Performing Products by Profit Margin</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{data.topProducts.map((product) => (
|
||||
<div key={`${product.product}-${product.category}`} className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{product.product}</p>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium">Category:</p>
|
||||
<p>{product.categoryPath}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Revenue: ${product.revenue.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{product.profitMargin.toFixed(1)}% margin
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cost: ${product.cost.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
inventory/src/components/analytics/SeasonalPatterns.tsx
Normal file
240
inventory/src/components/analytics/SeasonalPatterns.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Legend,
|
||||
Tooltip,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
import { Sun, Snowflake } from 'lucide-react';
|
||||
|
||||
interface PatternRow {
|
||||
pattern: string;
|
||||
productCount: number;
|
||||
stockCost: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
interface PeakSeasonRow {
|
||||
month: string;
|
||||
productCount: number;
|
||||
stockCost: number;
|
||||
}
|
||||
|
||||
interface SeasonalData {
|
||||
patterns: PatternRow[];
|
||||
peakSeasons: PeakSeasonRow[];
|
||||
}
|
||||
|
||||
const PATTERN_COLORS: Record<string, string> = {
|
||||
none: '#94a3b8', // slate — no seasonality
|
||||
moderate: METRIC_COLORS.comparison, // amber
|
||||
strong: METRIC_COLORS.revenue, // emerald
|
||||
unknown: '#cbd5e1', // light slate
|
||||
};
|
||||
|
||||
const PATTERN_LABELS: Record<string, string> = {
|
||||
none: 'No Seasonality',
|
||||
moderate: 'Moderate',
|
||||
strong: 'Strong',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
export function SeasonalPatterns() {
|
||||
const { data, isLoading, isError } = useQuery<SeasonalData>({
|
||||
queryKey: ['seasonal-patterns'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/seasonal`);
|
||||
if (!response.ok) throw new Error('Failed to fetch seasonal data');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Seasonal Patterns</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load seasonal data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Seasonal Patterns</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading seasonal data...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const seasonal = data.patterns.filter(p => p.pattern === 'moderate' || p.pattern === 'strong');
|
||||
const seasonalCount = seasonal.reduce((s, p) => s + p.productCount, 0);
|
||||
const seasonalStockCost = seasonal.reduce((s, p) => s + p.stockCost, 0);
|
||||
const totalProducts = data.patterns.reduce((s, p) => s + p.productCount, 0);
|
||||
const seasonalPct = totalProducts > 0 ? ((seasonalCount / totalProducts) * 100).toFixed(0) : '0';
|
||||
|
||||
const donutData = data.patterns.map(p => ({
|
||||
name: PATTERN_LABELS[p.pattern] || p.pattern,
|
||||
value: p.productCount,
|
||||
color: PATTERN_COLORS[p.pattern] || '#94a3b8',
|
||||
stockCost: p.stockCost,
|
||||
revenue: p.revenue,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-amber-500/10">
|
||||
<Sun className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Seasonal Products</p>
|
||||
<p className="text-xl font-bold">{seasonalCount.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{seasonalPct}% of in-stock products</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-emerald-500/10">
|
||||
<Snowflake className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Seasonal Stock Value</p>
|
||||
<p className="text-xl font-bold">{formatCurrency(seasonalStockCost)}</p>
|
||||
<p className="text-xs text-muted-foreground">capital in seasonal items</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-blue-500/10">
|
||||
<Sun className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Peak Months Tracked</p>
|
||||
<p className="text-xl font-bold">{data.peakSeasons.length}</p>
|
||||
<p className="text-xs text-muted-foreground">months with seasonal peaks</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Donut chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Demand Seasonality Distribution</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Products by seasonal demand pattern (in-stock only)
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={donutData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
label={({ name, value }) => `${name} (${value.toLocaleString()})`}
|
||||
>
|
||||
{donutData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">{d.name}</p>
|
||||
<p>{d.value.toLocaleString()} products</p>
|
||||
<p>Stock value: {formatCurrency(d.stockCost)}</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value) => <span className="text-xs">{value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Peak season bar chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Peak Season Distribution</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Which months seasonal products peak (moderate + strong patterns)
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.peakSeasons.length === 0 ? (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">No peak season data available</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data.peakSeasons}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as PeakSeasonRow;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">{d.month}</p>
|
||||
<p>{d.productCount.toLocaleString()} seasonal products peak</p>
|
||||
<p>Stock value: {formatCurrency(d.stockCost)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="productCount"
|
||||
fill={METRIC_COLORS.comparison}
|
||||
name="Products"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import config from '../../config';
|
||||
|
||||
interface StockData {
|
||||
turnoverByCategory: {
|
||||
category: string;
|
||||
turnoverRate: number;
|
||||
averageStock: number;
|
||||
totalSales: number;
|
||||
}[];
|
||||
stockLevels: {
|
||||
date: string;
|
||||
inStock: number;
|
||||
lowStock: number;
|
||||
outOfStock: number;
|
||||
}[];
|
||||
criticalItems: {
|
||||
product: string;
|
||||
sku: string;
|
||||
stockQuantity: number;
|
||||
reorderPoint: number;
|
||||
turnoverRate: number;
|
||||
daysUntilStockout: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function StockAnalysis() {
|
||||
const { data, isLoading, error } = useQuery<StockData>({
|
||||
queryKey: ['stock-analysis'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/stock`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
const rawData = await response.json();
|
||||
|
||||
if (!rawData || !rawData.turnoverByCategory) {
|
||||
return {
|
||||
turnoverByCategory: [],
|
||||
stockLevels: [],
|
||||
criticalItems: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
turnoverByCategory: (rawData.turnoverByCategory || []).map((item: any) => ({
|
||||
category: item.category || '',
|
||||
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
|
||||
averageStock: Number(item.averageStock || item.averagestock) || 0,
|
||||
totalSales: Number(item.totalSales || item.totalsales) || 0
|
||||
})),
|
||||
stockLevels: (rawData.stockLevels || []).map((item: any) => ({
|
||||
date: item.date || '',
|
||||
inStock: Number(item.inStock || item.instock) || 0,
|
||||
lowStock: Number(item.lowStock || item.lowstock) || 0,
|
||||
outOfStock: Number(item.outOfStock || item.outofstock) || 0
|
||||
})),
|
||||
criticalItems: (rawData.criticalItems || []).map((item: any) => ({
|
||||
product: item.product || '',
|
||||
sku: item.sku || '',
|
||||
stockQuantity: Number(item.stockQuantity || item.stockquantity) || 0,
|
||||
reorderPoint: Number(item.reorderPoint || item.reorderpoint) || 0,
|
||||
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
|
||||
daysUntilStockout: Number(item.daysUntilStockout || item.daysuntilstockout) || 0
|
||||
}))
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error fetching stock data:', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
retry: 1
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading stock analysis...</div>;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-red-500">
|
||||
Unable to load stock analysis. The stock metrics may need to be set up in the database.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Early return if no data to display
|
||||
if (
|
||||
data.turnoverByCategory.length === 0 &&
|
||||
data.stockLevels.length === 0 &&
|
||||
data.criticalItems.length === 0
|
||||
) {
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
No stock data available. This may be because the stock metrics haven't been calculated yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getStockStatus = (daysUntilStockout: number) => {
|
||||
if (daysUntilStockout <= 7) {
|
||||
return <Badge variant="destructive">Critical</Badge>;
|
||||
}
|
||||
if (daysUntilStockout <= 14) {
|
||||
return <Badge variant="outline">Warning</Badge>;
|
||||
}
|
||||
return <Badge variant="secondary">OK</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Turnover by Category</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data.turnoverByCategory}>
|
||||
<XAxis dataKey="category" />
|
||||
<YAxis tickFormatter={(value) => `${value.toFixed(1)}x`} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value.toFixed(1)}x`, 'Turnover Rate']}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="turnoverRate"
|
||||
fill="#fbbf24"
|
||||
name="Turnover Rate"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Level Trends</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data.stockLevels}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
labelFormatter={(label) => new Date(label).toLocaleDateString()}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="inStock"
|
||||
stroke="#4ade80"
|
||||
name="In Stock"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="lowStock"
|
||||
stroke="#fbbf24"
|
||||
name="Low Stock"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="outOfStock"
|
||||
stroke="#f87171"
|
||||
name="Out of Stock"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Critical Stock Items</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{data.criticalItems.map((item) => (
|
||||
<div key={`${item.sku}-${item.product}`} className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{item.product}</p>
|
||||
{getStockStatus(item.daysUntilStockout)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SKU: {item.sku}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 text-right space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{item.stockQuantity} in stock
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reorder at: {item.reorderPoint}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.daysUntilStockout} days until stockout
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
inventory/src/components/analytics/StockHealth.tsx
Normal file
257
inventory/src/components/analytics/StockHealth.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { AlertTriangle, ShieldCheck, DollarSign } from 'lucide-react';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface CoverBucket {
|
||||
bucket: string;
|
||||
productCount: number;
|
||||
stockCost: number;
|
||||
}
|
||||
|
||||
interface DemandPattern {
|
||||
pattern: string;
|
||||
productCount: number;
|
||||
revenue: number;
|
||||
stockCost: number;
|
||||
}
|
||||
|
||||
interface ServiceStats {
|
||||
avgFillRate: number;
|
||||
avgServiceLevel: number;
|
||||
totalStockoutIncidents: number;
|
||||
totalLostSalesIncidents: number;
|
||||
totalLostUnits: number;
|
||||
totalLostRevenue: number;
|
||||
productsWithStockouts: number;
|
||||
avgStockoutRate: number;
|
||||
}
|
||||
|
||||
interface StockHealthData {
|
||||
coverDistribution: CoverBucket[];
|
||||
demandPatterns: DemandPattern[];
|
||||
serviceStats: ServiceStats;
|
||||
}
|
||||
|
||||
// Color palette for demand pattern donut chart
|
||||
const DEMAND_COLORS = [
|
||||
METRIC_COLORS.revenue, // emerald
|
||||
METRIC_COLORS.orders, // blue
|
||||
METRIC_COLORS.comparison, // amber
|
||||
METRIC_COLORS.aov, // violet
|
||||
METRIC_COLORS.secondary, // cyan
|
||||
];
|
||||
|
||||
function getCoverColor(bucket: string): string {
|
||||
if (bucket.includes('Stockout')) return '#ef4444'; // red
|
||||
if (bucket.includes('1-7')) return METRIC_COLORS.expense; // orange — critical low
|
||||
if (bucket.includes('8-14')) return METRIC_COLORS.comparison; // amber — low
|
||||
if (bucket.includes('15-30')) return '#eab308'; // yellow — watch
|
||||
if (bucket.includes('31-60')) return METRIC_COLORS.revenue; // emerald — healthy
|
||||
if (bucket.includes('61-90')) return METRIC_COLORS.orders; // blue — comfortable
|
||||
if (bucket.includes('91-180')) return METRIC_COLORS.aov; // violet — high
|
||||
return METRIC_COLORS.secondary; // cyan — excess
|
||||
}
|
||||
|
||||
export function StockHealth() {
|
||||
const { data, isLoading, isError } = useQuery<StockHealthData>({
|
||||
queryKey: ['stock-health'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/stock-health`);
|
||||
if (!response.ok) throw new Error('Failed to fetch stock health');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Demand & Stock Health</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load stock health data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Demand & Stock Health</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading stock health...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { serviceStats } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Service Level Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-green-500/10">
|
||||
<ShieldCheck className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Fill Rate</p>
|
||||
<p className="text-xl font-bold">{serviceStats.avgFillRate}%</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-blue-500/10">
|
||||
<ShieldCheck className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Service Level</p>
|
||||
<p className="text-xl font-bold">{serviceStats.avgServiceLevel}%</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-red-500/10">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Stockout Incidents</p>
|
||||
<p className="text-xl font-bold">{serviceStats.totalStockoutIncidents.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{serviceStats.productsWithStockouts} products affected</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-amber-500/10">
|
||||
<DollarSign className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Est. Lost Revenue</p>
|
||||
<p className="text-xl font-bold">{formatCurrency(serviceStats.totalLostRevenue)}</p>
|
||||
<p className="text-xs text-muted-foreground">{Math.round(serviceStats.totalLostUnits).toLocaleString()} units</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Stock Cover Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Cover Distribution</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Days of stock cover across active replenishable products
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data.coverDistribution}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tick={{ fontSize: 10 }}
|
||||
angle={-30}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as CoverBucket;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">{d.bucket}</p>
|
||||
<p>{d.productCount.toLocaleString()} products</p>
|
||||
<p>Stock value: {formatCurrency(d.stockCost)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="productCount" name="Products" radius={[4, 4, 0, 0]}>
|
||||
{data.coverDistribution.map((entry, i) => (
|
||||
<Cell key={i} fill={getCoverColor(entry.bucket)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Demand Pattern Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Demand Patterns</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Distribution of demand variability across selling products
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.demandPatterns}
|
||||
dataKey="productCount"
|
||||
nameKey="pattern"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
label={({ pattern, productCount }) =>
|
||||
`${pattern} (${productCount.toLocaleString()})`
|
||||
}
|
||||
>
|
||||
{data.demandPatterns.map((_, i) => (
|
||||
<Cell key={i} fill={DEMAND_COLORS[i % DEMAND_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as DemandPattern;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1 capitalize">{d.pattern}</p>
|
||||
<p>{d.productCount.toLocaleString()} products</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
|
||||
<p>Stock value: {formatCurrency(d.stockCost)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value) => <span className="capitalize text-xs">{value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
inventory/src/components/analytics/StockoutRisk.tsx
Normal file
187
inventory/src/components/analytics/StockoutRisk.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
ZAxis,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface RiskProduct {
|
||||
title: string;
|
||||
sku: string;
|
||||
brand: string;
|
||||
leadTimeDays: number;
|
||||
sellsOutInDays: number;
|
||||
currentStock: number;
|
||||
velocityDaily: number;
|
||||
revenue30d: number;
|
||||
abcClass: string;
|
||||
}
|
||||
|
||||
interface RiskSummary {
|
||||
atRiskCount: number;
|
||||
criticalACount: number;
|
||||
atRiskRevenue: number;
|
||||
}
|
||||
|
||||
interface StockoutRiskData {
|
||||
summary: RiskSummary;
|
||||
products: RiskProduct[];
|
||||
}
|
||||
|
||||
function getRiskColor(product: RiskProduct): string {
|
||||
const buffer = product.sellsOutInDays - product.leadTimeDays;
|
||||
if (buffer <= 0) return '#ef4444'; // Already past lead time — critical
|
||||
if (buffer <= 7) return METRIC_COLORS.comparison; // Within a week — warning
|
||||
return METRIC_COLORS.revenue; // Healthy buffer
|
||||
}
|
||||
|
||||
export function StockoutRisk() {
|
||||
const { data, isLoading, isError } = useQuery<StockoutRiskData>({
|
||||
queryKey: ['stockout-risk'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/stockout-risk`);
|
||||
if (!response.ok) throw new Error('Failed to fetch stockout risk');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Reorder Risk</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load risk data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Reorder Risk</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading risk data...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, products } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">At Risk Products</p>
|
||||
<p className="text-2xl font-bold text-red-500">{summary.atRiskCount}</p>
|
||||
<p className="text-xs text-muted-foreground">sells out before lead time</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">Critical A-Class</p>
|
||||
<p className="text-2xl font-bold text-red-500">{summary.criticalACount}</p>
|
||||
<p className="text-xs text-muted-foreground">top sellers at risk</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">At-Risk Revenue</p>
|
||||
<p className="text-2xl font-bold text-amber-500">
|
||||
{formatCurrency(summary.atRiskRevenue)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">monthly revenue exposed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lead Time vs Sell-Out Timeline</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Products below the diagonal line will stock out before replenishment arrives (incl. on-order stock)
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<ScatterChart>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="leadTimeDays"
|
||||
name="Lead Time"
|
||||
tick={{ fontSize: 11 }}
|
||||
type="number"
|
||||
label={{ value: 'Lead Time (days)', position: 'insideBottom', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="sellsOutInDays"
|
||||
name="Sells Out In"
|
||||
tick={{ fontSize: 11 }}
|
||||
type="number"
|
||||
label={{ value: 'Sells Out In (days)', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
|
||||
/>
|
||||
<ZAxis dataKey="revenue30d" range={[30, 300]} name="Revenue" />
|
||||
{/* Diagonal risk line (y = x): products below this stock out before replenishment */}
|
||||
<Scatter
|
||||
data={(() => {
|
||||
const max = products.length > 0 ? Math.max(...products.map(d => Math.max(d.leadTimeDays, d.sellsOutInDays))) : 100;
|
||||
return [
|
||||
{ leadTimeDays: 0, sellsOutInDays: 0, revenue30d: 0 },
|
||||
{ leadTimeDays: max, sellsOutInDays: max, revenue30d: 0 },
|
||||
];
|
||||
})()}
|
||||
line={{ stroke: '#9ca3af', strokeDasharray: '6 3', strokeWidth: 1.5 }}
|
||||
shape={() => <circle r={0} />}
|
||||
legendType="none"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as RiskProduct;
|
||||
if (!d.title) return null; // skip diagonal line points
|
||||
const buffer = d.sellsOutInDays - d.leadTimeDays;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm max-w-xs">
|
||||
<p className="font-medium mb-1 truncate">{d.title}</p>
|
||||
<p className="text-xs text-muted-foreground mb-1">{d.sku} ({d.abcClass})</p>
|
||||
<p>Lead time: {d.leadTimeDays}d</p>
|
||||
<p>Sells out in: {d.sellsOutInDays}d</p>
|
||||
<p className={buffer <= 0 ? 'text-red-500 font-medium' : ''}>
|
||||
Buffer: {buffer}d {buffer <= 0 ? '(AT RISK)' : ''}
|
||||
</p>
|
||||
<p>Stock: {d.currentStock} units</p>
|
||||
<p>Velocity: {d.velocityDaily.toFixed(1)}/day</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Scatter data={products} fillOpacity={0.7}>
|
||||
{products.map((entry, i) => (
|
||||
<Cell key={i} fill={getRiskColor(entry)} />
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, ScatterChart, Scatter, ZAxis } from 'recharts';
|
||||
import config from '../../config';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface VendorData {
|
||||
performance: {
|
||||
vendor: string;
|
||||
salesVolume: number;
|
||||
profitMargin: number;
|
||||
stockTurnover: number;
|
||||
productCount: number;
|
||||
growth: number;
|
||||
}[];
|
||||
comparison?: {
|
||||
vendor: string;
|
||||
salesPerProduct: number;
|
||||
averageMargin: number;
|
||||
size: number;
|
||||
}[];
|
||||
trends?: {
|
||||
vendor: string;
|
||||
month: string;
|
||||
sales: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function VendorPerformance() {
|
||||
const [vendorData, setVendorData] = useState<VendorData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Use plain fetch to bypass cache issues with React Query
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Add cache-busting parameter
|
||||
const response = await fetch(`${config.apiUrl}/analytics/vendors?nocache=${Date.now()}`, {
|
||||
headers: {
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0"
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
if (!rawData || !rawData.performance) {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
|
||||
// Create a complete structure even if some parts are missing
|
||||
const data: VendorData = {
|
||||
performance: rawData.performance.map((vendor: any) => ({
|
||||
vendor: vendor.vendor || '',
|
||||
salesVolume: vendor.salesVolume !== null ? Number(vendor.salesVolume) : 0,
|
||||
profitMargin: vendor.profitMargin !== null ? Number(vendor.profitMargin) : 0,
|
||||
stockTurnover: vendor.stockTurnover !== null ? Number(vendor.stockTurnover) : 0,
|
||||
productCount: Number(vendor.productCount) || 0,
|
||||
growth: vendor.growth !== null ? Number(vendor.growth) : 0
|
||||
})),
|
||||
comparison: rawData.comparison?.map((vendor: any) => ({
|
||||
vendor: vendor.vendor || '',
|
||||
salesPerProduct: vendor.salesPerProduct !== null ? Number(vendor.salesPerProduct) : 0,
|
||||
averageMargin: vendor.averageMargin !== null ? Number(vendor.averageMargin) : 0,
|
||||
size: Number(vendor.size) || 0
|
||||
})) || [],
|
||||
trends: rawData.trends?.map((vendor: any) => ({
|
||||
vendor: vendor.vendor || '',
|
||||
month: vendor.month || '',
|
||||
sales: Number(vendor.sales) || 0
|
||||
})) || []
|
||||
};
|
||||
|
||||
setVendorData(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching vendor data:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading vendor performance...</div>;
|
||||
}
|
||||
|
||||
if (error || !vendorData) {
|
||||
return <div className="text-red-500">Error loading vendor data: {error}</div>;
|
||||
}
|
||||
|
||||
// Ensure we have at least the performance data
|
||||
const sortedPerformance = vendorData.performance
|
||||
.sort((a, b) => b.salesVolume - a.salesVolume)
|
||||
.slice(0, 10);
|
||||
|
||||
// Use simplified version if comparison data is missing
|
||||
const hasComparisonData = vendorData.comparison && vendorData.comparison.length > 0;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Vendors by Sales Volume</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={sortedPerformance}>
|
||||
<XAxis dataKey="vendor" />
|
||||
<YAxis tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales Volume']}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="salesVolume"
|
||||
fill="#60a5fa"
|
||||
name="Sales Volume"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasComparisonData ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vendor Performance Matrix</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart>
|
||||
<XAxis
|
||||
dataKey="salesPerProduct"
|
||||
name="Sales per Product"
|
||||
tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="averageMargin"
|
||||
name="Average Margin"
|
||||
tickFormatter={(value) => `${value.toFixed(0)}%`}
|
||||
/>
|
||||
<ZAxis
|
||||
dataKey="size"
|
||||
range={[50, 400]}
|
||||
name="Product Count"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Sales per Product') return [`$${value.toLocaleString()}`, name];
|
||||
if (name === 'Average Margin') return [`${value.toFixed(1)}%`, name];
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Scatter
|
||||
data={vendorData.comparison}
|
||||
fill="#60a5fa"
|
||||
name="Vendors"
|
||||
/>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vendor Profit Margins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={sortedPerformance}>
|
||||
<XAxis dataKey="vendor" />
|
||||
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="profitMargin"
|
||||
fill="#4ade80"
|
||||
name="Profit Margin"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vendor Performance Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{sortedPerformance.map((vendor) => (
|
||||
<div key={`${vendor.vendor}-${vendor.salesVolume}`} className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{vendor.vendor}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{vendor.productCount} products
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 text-right space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
${vendor.salesVolume.toLocaleString()} sales
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{vendor.profitMargin.toFixed(1)}% margin
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{vendor.stockTurnover.toFixed(1)}x turnover
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
440
inventory/src/components/bulk-edit/BulkEditRow.tsx
Normal file
440
inventory/src/components/bulk-edit/BulkEditRow.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Loader2,
|
||||
Check,
|
||||
X,
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
AlertCircle,
|
||||
Save,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SearchProduct, FieldOption } from "@/components/product-editor/types";
|
||||
|
||||
const PROD_IMG_HOST = "https://sbing.com";
|
||||
const BACKEND_URL = "https://backend.acherryontop.com/product";
|
||||
|
||||
export type BulkEditFieldChoice =
|
||||
| "name"
|
||||
| "description"
|
||||
| "categories"
|
||||
| "themes"
|
||||
| "colors"
|
||||
| "tax_cat"
|
||||
| "size_cat"
|
||||
| "ship_restrictions"
|
||||
| "hts_code"
|
||||
| "weight"
|
||||
| "msrp"
|
||||
| "cost_each";
|
||||
|
||||
export interface AiResult {
|
||||
isValid: boolean;
|
||||
suggestion?: string | null;
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
export interface RowAiState {
|
||||
status: "idle" | "validating" | "done";
|
||||
result: AiResult | null;
|
||||
editedSuggestion: string | null;
|
||||
decision: "accepted" | "dismissed" | null;
|
||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||
saveError: string | null;
|
||||
/** Track manual edits to the main field value */
|
||||
manualEdit: string | null;
|
||||
}
|
||||
|
||||
export const INITIAL_ROW_STATE: RowAiState = {
|
||||
status: "idle",
|
||||
result: null,
|
||||
editedSuggestion: null,
|
||||
decision: null,
|
||||
saveStatus: "idle",
|
||||
saveError: null,
|
||||
manualEdit: null,
|
||||
};
|
||||
|
||||
/** Fields that support AI validation */
|
||||
export const AI_FIELDS: BulkEditFieldChoice[] = ["name", "description"];
|
||||
|
||||
/** Field display config */
|
||||
export const FIELD_OPTIONS: { value: BulkEditFieldChoice; label: string; ai?: boolean }[] = [
|
||||
{ value: "description", label: "Description", ai: true },
|
||||
{ value: "name", label: "Name", ai: true },
|
||||
{ value: "hts_code", label: "HTS Code" },
|
||||
{ value: "weight", label: "Weight" },
|
||||
{ value: "msrp", label: "MSRP" },
|
||||
{ value: "cost_each", label: "Cost Each" },
|
||||
{ value: "tax_cat", label: "Tax Category" },
|
||||
{ value: "size_cat", label: "Size Category" },
|
||||
{ value: "ship_restrictions", label: "Shipping Restrictions" },
|
||||
];
|
||||
|
||||
/** Get the current raw value for a field from SearchProduct */
|
||||
export function getFieldValue(product: SearchProduct, field: BulkEditFieldChoice): string {
|
||||
switch (field) {
|
||||
case "name": return product.title ?? "";
|
||||
case "description": return product.description ?? "";
|
||||
case "hts_code": return product.harmonized_tariff_code ?? "";
|
||||
case "weight": return product.weight != null ? String(product.weight) : "";
|
||||
case "msrp": return product.regular_price != null ? String(product.regular_price) : "";
|
||||
case "cost_each": return product.cost_price != null ? String(product.cost_price) : "";
|
||||
case "tax_cat": return product.tax_code ?? "";
|
||||
case "size_cat": return product.size_cat ?? "";
|
||||
case "ship_restrictions": return product.shipping_restrictions ?? "";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the backend field key for submission */
|
||||
export function getSubmitFieldKey(field: BulkEditFieldChoice): string {
|
||||
switch (field) {
|
||||
case "name": return "description"; // backend field is "description" for product name
|
||||
case "description": return "notes"; // backend uses "notes" for product description
|
||||
case "hts_code": return "harmonized_tariff_code";
|
||||
case "msrp": return "sellingprice";
|
||||
case "cost_each": return "cost_each";
|
||||
case "tax_cat": return "tax_code";
|
||||
case "size_cat": return "size_cat";
|
||||
case "ship_restrictions": return "shipping_restrictions";
|
||||
default: return field;
|
||||
}
|
||||
}
|
||||
|
||||
interface BulkEditRowProps {
|
||||
product: SearchProduct;
|
||||
field: BulkEditFieldChoice;
|
||||
state: RowAiState;
|
||||
imageUrl: string | null;
|
||||
selectOptions?: FieldOption[];
|
||||
onAccept: (pid: number, value: string) => void;
|
||||
onDismiss: (pid: number) => void;
|
||||
onManualEdit: (pid: number, value: string) => void;
|
||||
onEditSuggestion: (pid: number, value: string) => void;
|
||||
}
|
||||
|
||||
export function BulkEditRow({
|
||||
product,
|
||||
field,
|
||||
state,
|
||||
imageUrl,
|
||||
selectOptions,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onManualEdit,
|
||||
onEditSuggestion,
|
||||
}: BulkEditRowProps) {
|
||||
const currentValue = state.manualEdit ?? getFieldValue(product, field);
|
||||
const hasAiSuggestion =
|
||||
state.status === "done" && state.result && !state.result.isValid && state.result.suggestion;
|
||||
const isValid = state.status === "done" && state.result?.isValid;
|
||||
const isAccepted = state.decision === "accepted";
|
||||
const isDismissed = state.decision === "dismissed";
|
||||
const isValidating = state.status === "validating";
|
||||
const showSuggestion = hasAiSuggestion && !isDismissed && !isAccepted;
|
||||
const backendUrl = `${BACKEND_URL}/${product.pid}`;
|
||||
|
||||
// Determine border color based on state
|
||||
const borderClass = isAccepted
|
||||
? "border-l-4 border-l-green-500"
|
||||
: state.saveStatus === "saved"
|
||||
? "border-l-4 border-l-green-300"
|
||||
: state.saveStatus === "error"
|
||||
? "border-l-4 border-l-destructive"
|
||||
: "";
|
||||
|
||||
const renderFieldEditor = () => {
|
||||
// If this is a select field, render a select
|
||||
if (selectOptions) {
|
||||
return (
|
||||
<Select
|
||||
value={currentValue}
|
||||
onValueChange={(v) => onManualEdit(product.pid, v)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-8 text-sm">
|
||||
<SelectValue placeholder="Select..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// Description gets a textarea with inline spinner
|
||||
if (field === "description") {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
value={currentValue}
|
||||
onChange={(e) => onManualEdit(product.pid, e.target.value)}
|
||||
className={cn(
|
||||
"text-sm min-h-[60px] max-h-[120px] resize-y",
|
||||
isValidating && "pr-8"
|
||||
)}
|
||||
rows={2}
|
||||
/>
|
||||
{isValidating && (
|
||||
<Loader2 className="absolute top-2 right-2 h-4 w-4 animate-spin text-purple-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Name/text fields with inline spinner
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={currentValue}
|
||||
onChange={(e) => onManualEdit(product.pid, e.target.value)}
|
||||
className={cn("h-8 text-sm", isValidating && "pr-8")}
|
||||
/>
|
||||
{isValidating && (
|
||||
<Loader2 className="absolute top-2 right-2 h-3.5 w-3.5 animate-spin text-purple-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Inline AI suggestion panel for name / short text fields
|
||||
const renderNameSuggestion = () => {
|
||||
if (!showSuggestion) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
|
||||
{/* Issues */}
|
||||
{state.result!.issues.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{state.result!.issues.map((issue, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-1 text-[11px] text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||
<span>{issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Editable suggestion + actions */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500 shrink-0" />
|
||||
<Input
|
||||
value={state.editedSuggestion ?? state.result!.suggestion!}
|
||||
onChange={(e) => onEditSuggestion(product.pid, e.target.value)}
|
||||
className="h-7 text-sm flex-1 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 bg-purple-50/50 dark:bg-purple-950/20"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs shrink-0 bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||
onClick={() =>
|
||||
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
|
||||
}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-1.5 text-xs text-gray-500 hover:text-gray-700 shrink-0"
|
||||
onClick={() => onDismiss(product.pid)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Inline AI suggestion panel for description (larger, stacked)
|
||||
const renderDescriptionSuggestion = () => {
|
||||
if (!showSuggestion) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-0 flex-1 bg-purple-50/60 dark:bg-purple-950/20 rounded-md p-2">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||
AI Suggestion
|
||||
</span>
|
||||
{state.result!.issues.length > 0 && (
|
||||
<span className="text-[11px] text-purple-500">
|
||||
({state.result!.issues.length} {state.result!.issues.length === 1 ? "issue" : "issues"})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Issues */}
|
||||
{state.result!.issues.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{state.result!.issues.map((issue, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-1 text-[11px] text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||
<span>{issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editable suggestion */}
|
||||
<Textarea
|
||||
value={state.editedSuggestion ?? state.result!.suggestion!}
|
||||
onChange={(e) => onEditSuggestion(product.pid, e.target.value)}
|
||||
className="text-sm min-h-[60px] max-h-[120px] resize-y border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 bg-white dark:bg-black/20"
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||
onClick={() =>
|
||||
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
|
||||
}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs text-gray-500 hover:text-gray-700"
|
||||
onClick={() => onDismiss(product.pid)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Status icon (right edge)
|
||||
const renderStatus = () => {
|
||||
// Don't show spinner here anymore — it's in the field editor
|
||||
if (isValid && !isAccepted) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>No changes needed</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (isAccepted) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Change accepted</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (state.saveStatus === "saving") {
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-primary" />;
|
||||
}
|
||||
if (state.saveStatus === "saved") {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Save className="h-4 w-4 text-green-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Saved</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (state.saveStatus === "error") {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{state.saveError || "Save failed"}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-card transition-colors", borderClass)}>
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
{/* Thumbnail */}
|
||||
<a
|
||||
href={backendUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block h-12 w-12 shrink-0 overflow-hidden rounded-md border bg-muted"
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl.startsWith("/") ? PROD_IMG_HOST + imageUrl : imageUrl}
|
||||
alt={product.title}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-muted-foreground">
|
||||
No img
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Identity */}
|
||||
<div className="flex flex-col gap-0.5 min-w-0 w-44 shrink-0">
|
||||
<a
|
||||
href={backendUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-medium text-primary hover:underline flex items-center gap-1 truncate"
|
||||
>
|
||||
<span className="truncate">{product.title}</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0" />
|
||||
</a>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
UPC: {product.barcode || "—"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
SKU: {product.sku || "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Field editor */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{renderFieldEditor()}
|
||||
</div>
|
||||
|
||||
{/* AI suggestion inline (same row) */}
|
||||
{field === "description"
|
||||
? renderDescriptionSuggestion()
|
||||
: renderNameSuggestion()
|
||||
}
|
||||
|
||||
{/* Status icon */}
|
||||
<div className="flex items-center shrink-0 w-6 justify-center pt-1">
|
||||
{renderStatus()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FileSearch,
|
||||
ShoppingCart,
|
||||
FilePenLine,
|
||||
PenLine,
|
||||
Mail,
|
||||
} from "lucide-react";
|
||||
import { IconCrystalBall } from "@tabler/icons-react";
|
||||
@@ -122,6 +123,12 @@ const toolsItems = [
|
||||
url: "/product-editor",
|
||||
permission: "access:product_editor"
|
||||
},
|
||||
{
|
||||
title: "Bulk Edit",
|
||||
icon: PenLine,
|
||||
url: "/bulk-edit",
|
||||
permission: "access:bulk_edit"
|
||||
},
|
||||
{
|
||||
title: "Newsletter",
|
||||
icon: Mail,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
|
||||
interface Product {
|
||||
pid: number;
|
||||
@@ -22,7 +22,6 @@ interface Category {
|
||||
units_sold: number;
|
||||
revenue: string;
|
||||
profit: string;
|
||||
growth_rate: string;
|
||||
}
|
||||
|
||||
interface BestSellerBrand {
|
||||
@@ -39,14 +38,22 @@ interface BestSellersData {
|
||||
categories: Category[]
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BestSellers() {
|
||||
const { data } = useQuery<BestSellersData>({
|
||||
const { data, isError, isLoading } = useQuery<BestSellersData>({
|
||||
queryKey: ["best-sellers"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/best-sellers`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch best sellers")
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to fetch best sellers");
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
@@ -65,109 +72,119 @@ export function BestSellers() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TabsContent value="products">
|
||||
<ScrollArea className="h-[385px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Units Sold</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Profit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.products.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.units_sold}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(product.revenue))}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(product.profit))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load best sellers</p>
|
||||
) : isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<TabsContent value="products">
|
||||
<ScrollArea className="h-[420px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Units Sold</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Profit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.products.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.units_sold}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(product.revenue))}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(product.profit))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="brands">
|
||||
<ScrollArea className="h-[400px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40%]">Brand</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.brands.map((brand) => (
|
||||
<TableRow key={brand.brand}>
|
||||
<TableCell className="w-[40%]">
|
||||
<p className="font-medium">{brand.brand}</p>
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{brand.units_sold.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{formatCurrency(Number(brand.revenue))}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{formatCurrency(Number(brand.profit))}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<TabsContent value="brands">
|
||||
<ScrollArea className="h-[400px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40%]">Brand</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.brands.map((brand) => (
|
||||
<TableRow key={brand.brand}>
|
||||
<TableCell className="w-[40%]">
|
||||
<p className="font-medium">{brand.brand}</p>
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{brand.units_sold.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{formatCurrency(Number(brand.revenue))}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{formatCurrency(Number(brand.profit))}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{brand.growth_rate != null ? (
|
||||
<>{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}%</>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="categories">
|
||||
<ScrollArea className="h-[400px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead className="text-right">Units Sold</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Profit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.categories.map((category) => (
|
||||
<TableRow key={category.cat_id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{category.name}</div>
|
||||
{category.categoryPath && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{category.categoryPath}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{category.units_sold}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(category.revenue))}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(category.profit))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<TabsContent value="categories">
|
||||
<ScrollArea className="h-[400px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead className="text-right">Units Sold</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Profit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.categories.map((category) => (
|
||||
<TableRow key={category.cat_id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{category.name}</div>
|
||||
{category.categoryPath && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{category.categoryPath}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{category.units_sold}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(category.revenue))}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(category.profit))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Tabs>
|
||||
</>
|
||||
|
||||
294
inventory/src/components/overview/ForecastAccuracy.tsx
Normal file
294
inventory/src/components/overview/ForecastAccuracy.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { BarChart, Bar, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip, Cell, LineChart, Line } from "recharts"
|
||||
import config from "@/config"
|
||||
import { Target, TrendingDown, ArrowUpDown } from "lucide-react"
|
||||
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { PHASE_CONFIG } from "@/utils/lifecyclePhases"
|
||||
|
||||
interface OverallMetrics {
|
||||
sampleSize: number
|
||||
totalActual: number
|
||||
totalForecast: number
|
||||
mae: number | null
|
||||
wmape: number | null
|
||||
bias: number | null
|
||||
rmse: number | null
|
||||
}
|
||||
|
||||
interface PhaseAccuracy {
|
||||
phase: string
|
||||
sampleSize: number
|
||||
totalActual: number
|
||||
totalForecast: number
|
||||
mae: number | null
|
||||
wmape: number | null
|
||||
bias: number | null
|
||||
rmse: number | null
|
||||
}
|
||||
|
||||
interface LeadTimeAccuracy {
|
||||
bucket: string
|
||||
sampleSize: number
|
||||
mae: number | null
|
||||
wmape: number | null
|
||||
bias: number | null
|
||||
rmse: number | null
|
||||
}
|
||||
|
||||
interface AccuracyTrendPoint {
|
||||
date: string
|
||||
mae: number | null
|
||||
wmape: number | null
|
||||
bias: number | null
|
||||
sampleSize: number
|
||||
}
|
||||
|
||||
interface AccuracyData {
|
||||
hasData: boolean
|
||||
message?: string
|
||||
computedAt?: string
|
||||
daysOfHistory?: number
|
||||
historyRange?: { from: string; to: string }
|
||||
overall?: OverallMetrics
|
||||
byPhase?: PhaseAccuracy[]
|
||||
byLeadTime?: LeadTimeAccuracy[]
|
||||
byMethod?: { method: string; sampleSize: number; mae: number | null; wmape: number | null; bias: number | null }[]
|
||||
dailyTrend?: { date: string; mae: number | null; wmape: number | null; bias: number | null }[]
|
||||
accuracyTrend?: AccuracyTrendPoint[]
|
||||
}
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-16 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
function formatWmape(wmape: number | null): string {
|
||||
if (wmape === null) return "N/A"
|
||||
return `${wmape.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatBias(bias: number | null): string {
|
||||
if (bias === null) return "N/A"
|
||||
const sign = bias > 0 ? "+" : ""
|
||||
return `${sign}${bias.toFixed(3)}`
|
||||
}
|
||||
|
||||
function getAccuracyColor(wmape: number | null): string {
|
||||
if (wmape === null) return "text-muted-foreground"
|
||||
if (wmape <= 30) return "text-green-600"
|
||||
if (wmape <= 50) return "text-yellow-600"
|
||||
return "text-red-600"
|
||||
}
|
||||
|
||||
export function ForecastAccuracy() {
|
||||
const { data, error, isLoading } = useQuery<AccuracyData>({
|
||||
queryKey: ["forecast-accuracy"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/forecast/accuracy`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch forecast accuracy")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
refetchInterval: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">Forecast Accuracy</h3>
|
||||
<p className="text-sm text-destructive">Failed to load accuracy data</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoading && data && !data.hasData) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">Forecast Accuracy</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Accuracy data will be available after the forecast engine has run for at least 2 days,
|
||||
building up historical comparisons between predictions and actual sales.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const phaseChartData = (data?.byPhase || [])
|
||||
.filter(p => p.wmape !== null && p.phase !== 'dormant')
|
||||
.map(p => ({
|
||||
phase: PHASE_CONFIG[p.phase]?.label || p.phase,
|
||||
rawPhase: p.phase,
|
||||
wmape: p.wmape,
|
||||
mae: p.mae,
|
||||
bias: p.bias,
|
||||
sampleSize: p.sampleSize,
|
||||
}))
|
||||
.sort((a, b) => (a.wmape ?? 100) - (b.wmape ?? 100))
|
||||
|
||||
const leadTimeData = (data?.byLeadTime || []).map(lt => ({
|
||||
bucket: lt.bucket,
|
||||
wmape: lt.wmape,
|
||||
mae: lt.mae,
|
||||
sampleSize: lt.sampleSize,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">Forecast Accuracy</h3>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<MetricSkeleton />
|
||||
<MetricSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Headline metrics */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">WMAPE</p>
|
||||
</div>
|
||||
<p className={`text-lg font-bold ${getAccuracyColor(data?.overall?.wmape ?? null)}`}>
|
||||
{formatWmape(data?.overall?.wmape ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">MAE</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">
|
||||
{data?.overall?.mae !== null ? data?.overall?.mae?.toFixed(2) : "N/A"}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-1">units</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Bias</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">
|
||||
{formatBias(data?.overall?.bias ?? null)}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-1">
|
||||
{(data?.overall?.bias ?? 0) > 0 ? "over" : (data?.overall?.bias ?? 0) < 0 ? "under" : ""}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase accuracy bar */}
|
||||
{phaseChartData.length > 0 && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">WMAPE by Lifecycle Phase</p>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="space-y-1">
|
||||
{phaseChartData.map((p) => {
|
||||
const cfg = PHASE_CONFIG[p.rawPhase] || { label: p.phase, color: "#94A3B8" }
|
||||
const maxWmape = Math.max(...phaseChartData.map(d => d.wmape ?? 0), 1)
|
||||
const barWidth = ((p.wmape ?? 0) / maxWmape) * 100
|
||||
return (
|
||||
<UITooltip key={p.rawPhase}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground w-16 text-right shrink-0">{cfg.label}</span>
|
||||
<div className="flex-1 h-3 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${barWidth}%`,
|
||||
backgroundColor: cfg.color,
|
||||
minWidth: barWidth > 0 ? 4 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium w-10 text-right shrink-0">
|
||||
{formatWmape(p.wmape)}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<div className="font-medium">{cfg.label}</div>
|
||||
<div>WMAPE: {formatWmape(p.wmape)}</div>
|
||||
<div>MAE: {p.mae?.toFixed(3) ?? "N/A"} units</div>
|
||||
<div>Bias: {formatBias(p.bias)}</div>
|
||||
<div className="text-muted-foreground">{p.sampleSize.toLocaleString()} samples</div>
|
||||
</TooltipContent>
|
||||
</UITooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lead time accuracy chart */}
|
||||
{leadTimeData.length > 0 && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Accuracy by Lead Time</p>
|
||||
<div className="h-[120px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={leadTimeData} margin={{ top: 5, right: 0, left: -30, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
formatter={(value: number) => [`${value?.toFixed(1)}%`, "WMAPE"]}
|
||||
/>
|
||||
<Bar dataKey="wmape" radius={[4, 4, 0, 0]}>
|
||||
{leadTimeData.map((entry, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
fill={(entry.wmape ?? 0) <= 30 ? "#22C55E" : (entry.wmape ?? 0) <= 50 ? "#F59E0B" : "#EF4444"}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accuracy trend sparkline */}
|
||||
{data?.accuracyTrend && data.accuracyTrend.length > 1 && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Accuracy Trend (WMAPE)</p>
|
||||
<div className="h-[60px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data.accuracyTrend} margin={{ top: 5, right: 0, left: -60, bottom: 0 }}>
|
||||
<YAxis tickLine={false} axisLine={false} tick={false} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="wmape"
|
||||
stroke="#8884D8"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer info */}
|
||||
{data?.daysOfHistory !== undefined && (
|
||||
<p className="text-[10px] text-muted-foreground mt-3 mb-2">
|
||||
Based on {data.daysOfHistory} day{data.daysOfHistory !== 1 ? "s" : ""} of history
|
||||
{data.overall?.sampleSize ? ` (${data.overall.sampleSize.toLocaleString()} samples)` : ""}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,50 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
|
||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts"
|
||||
import { useState } from "react"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { TrendingUp, DollarSign } from "lucide-react"
|
||||
import { DateRange } from "react-day-picker"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { TrendingUp, DollarSign, Target } from "lucide-react"
|
||||
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ForecastAccuracy } from "@/components/overview/ForecastAccuracy"
|
||||
import { addDays, format } from "date-fns"
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
||||
import { PHASE_CONFIG, PHASE_KEYS } from "@/utils/lifecyclePhases"
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
type Period = 30 | 90 | 'year';
|
||||
|
||||
function getEndDate(period: Period): Date {
|
||||
if (period === 'year') return new Date(new Date().getFullYear(), 11, 31);
|
||||
return addDays(new Date(), period);
|
||||
}
|
||||
|
||||
interface PhaseData {
|
||||
phase: string
|
||||
products: number
|
||||
units: number
|
||||
revenue: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
interface DailyPhaseData {
|
||||
date: string
|
||||
preorder: number
|
||||
launch: number
|
||||
decay: number
|
||||
mature: number
|
||||
slow_mover: number
|
||||
dormant: number
|
||||
}
|
||||
|
||||
interface ForecastData {
|
||||
forecastSales: number
|
||||
forecastRevenue: string
|
||||
forecastRevenue: number
|
||||
confidenceLevel: number
|
||||
dailyForecasts: {
|
||||
date: string
|
||||
@@ -19,6 +52,8 @@ interface ForecastData {
|
||||
revenue: string
|
||||
confidence: number
|
||||
}[]
|
||||
dailyForecastsByPhase?: DailyPhaseData[]
|
||||
phaseBreakdown?: PhaseData[]
|
||||
categoryForecasts: {
|
||||
category: string
|
||||
units: number
|
||||
@@ -28,17 +63,14 @@ interface ForecastData {
|
||||
}
|
||||
|
||||
export function ForecastMetrics() {
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
from: new Date(),
|
||||
to: addDays(new Date(), 30),
|
||||
});
|
||||
const [period, setPeriod] = useState<Period>(30);
|
||||
|
||||
const { data, error, isLoading } = useQuery<ForecastData>({
|
||||
queryKey: ["forecast-metrics", dateRange],
|
||||
queryKey: ["forecast-metrics", period],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
startDate: dateRange.from?.toISOString() || "",
|
||||
endDate: dateRange.to?.toISOString() || "",
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: getEndDate(period).toISOString(),
|
||||
});
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?${params}`)
|
||||
if (!response.ok) {
|
||||
@@ -50,25 +82,35 @@ export function ForecastMetrics() {
|
||||
},
|
||||
})
|
||||
|
||||
const hasPhaseData = data?.dailyForecastsByPhase && data.dailyForecastsByPhase.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
||||
<CardTitle className="text-xl font-medium">Forecast</CardTitle>
|
||||
<div className="w-[230px]">
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={(range) => {
|
||||
if (range) setDateRange(range);
|
||||
}}
|
||||
future={true}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Target className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[400px]">
|
||||
<ForecastAccuracy />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Tabs value={String(period)} onValueChange={(v) => setPeriod(v === 'year' ? 'year' : Number(v) as Period)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="30">30D</TabsTrigger>
|
||||
<TabsTrigger value="90">90D</TabsTrigger>
|
||||
<TabsTrigger value="year">EOY</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-0 -mb-2">
|
||||
{error ? (
|
||||
<div className="text-sm text-red-500">Error: {error.message}</div>
|
||||
) : isLoading ? (
|
||||
<div className="text-sm">Loading forecast metrics...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -77,48 +119,121 @@ export function ForecastMetrics() {
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Forecast Sales</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.forecastSales.toLocaleString() || 0}</p>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.forecastSales.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.forecastRevenue) || 0)}</p>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.forecastRevenue)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Forecast Revenue By Lifecycle Phase</p>
|
||||
<div className="h-2.5 w-full animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
) : data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Forecast Revenue By Lifecycle Phase</p>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="flex h-2.5 w-full overflow-hidden rounded-full">
|
||||
{data.phaseBreakdown.map((p) => {
|
||||
const cfg = PHASE_CONFIG[p.phase] || { label: p.phase, color: "#94A3B8" }
|
||||
return (
|
||||
<UITooltip key={p.phase}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${p.percentage}%`,
|
||||
backgroundColor: cfg.color,
|
||||
minWidth: p.percentage > 0 ? 4 : 0,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<div className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: cfg.color }} />
|
||||
{cfg.label}
|
||||
<span className="font-normal opacity-70">{p.percentage}%</span>
|
||||
</div>
|
||||
<div className="mt-0.5 font-semibold">{formatCurrency(p.revenue)}</div>
|
||||
<div className="opacity-70">{p.products.toLocaleString()} products</div>
|
||||
</TooltipContent>
|
||||
</UITooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-[250px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data?.dailyForecasts || []}
|
||||
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
|
||||
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#8884D8"
|
||||
fill="#8884D8"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-[200px] w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={hasPhaseData ? data.dailyForecastsByPhase : (data?.dailyForecasts || [])}
|
||||
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
const cfg = PHASE_CONFIG[name]
|
||||
return [formatCurrency(value), cfg?.label || name]
|
||||
}}
|
||||
labelFormatter={(date) => format(new Date(date + 'T00:00:00'), 'MMM d, yyyy')}
|
||||
itemSorter={(item) => -(item.value as number || 0)}
|
||||
/>
|
||||
{hasPhaseData ? (
|
||||
PHASE_KEYS.map((phase) => {
|
||||
const cfg = PHASE_CONFIG[phase]
|
||||
return (
|
||||
<Area
|
||||
key={phase}
|
||||
type="monotone"
|
||||
dataKey={phase}
|
||||
name={phase}
|
||||
stackId="a"
|
||||
stroke={cfg.color}
|
||||
fill={cfg.color}
|
||||
fillOpacity={0.6}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#8884D8"
|
||||
fill="#8884D8"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { AlertTriangle, Layers, DollarSign, Tag } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { PHASE_CONFIG } from "@/utils/lifecyclePhases"
|
||||
|
||||
interface PhaseBreakdown {
|
||||
phase: string
|
||||
products: number
|
||||
units: number
|
||||
cost: number
|
||||
retail: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
interface OverstockMetricsData {
|
||||
overstockedProducts: number
|
||||
total_excess_units: number
|
||||
total_excess_cost: number
|
||||
total_excess_retail: number
|
||||
category_data: {
|
||||
totalExcessUnits: number
|
||||
totalExcessCost: number
|
||||
totalExcessRetail: number
|
||||
categoryData: {
|
||||
category: string
|
||||
products: number
|
||||
units: number
|
||||
cost: number
|
||||
retail: number
|
||||
}[]
|
||||
phaseBreakdown?: PhaseBreakdown[]
|
||||
}
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
export function OverstockMetrics() {
|
||||
const { data } = useQuery<OverstockMetricsData>({
|
||||
const { data, isError, isLoading } = useQuery<OverstockMetricsData>({
|
||||
queryKey: ["overstock-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch overstock metrics")
|
||||
}
|
||||
return response.json()
|
||||
if (!response.ok) throw new Error('Failed to fetch overstock metrics');
|
||||
return response.json();
|
||||
},
|
||||
})
|
||||
|
||||
@@ -36,36 +50,83 @@ export function OverstockMetrics() {
|
||||
<CardTitle className="text-xl font-medium">Overstock</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Products</p>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load overstock metrics</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Products</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.overstockedProducts.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.overstockedProducts.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.totalExcessUnits.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.total_excess_units.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.totalExcessCost)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_cost || 0)}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.totalExcessRetail)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_retail || 0)}</p>
|
||||
{data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (
|
||||
<div className="mt-1 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Overstocked Cost By Lifecycle Phase</p>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="flex h-2.5 w-full overflow-hidden rounded-full">
|
||||
{data.phaseBreakdown.map((p) => {
|
||||
const cfg = PHASE_CONFIG[p.phase] || { label: p.phase, color: "#94A3B8" }
|
||||
return (
|
||||
<Tooltip key={p.phase}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${p.percentage}%`,
|
||||
backgroundColor: cfg.color,
|
||||
minWidth: p.percentage > 0 ? 3 : 0,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<div className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: cfg.color }} />
|
||||
{cfg.label}
|
||||
<span className="font-normal opacity-70">{p.percentage}%</span>
|
||||
</div>
|
||||
<div className="mt-0.5 font-semibold">{formatCurrency(p.cost)}</div>
|
||||
<div className="opacity-70">{p.products} products · {p.units} units</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import config from '../../config';
|
||||
|
||||
interface SalesData {
|
||||
date: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
const { data, isLoading, error } = useQuery<SalesData[]>({
|
||||
queryKey: ['sales-overview'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/sales-overview`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch sales overview');
|
||||
}
|
||||
const rawData = await response.json();
|
||||
return rawData.map((item: SalesData) => ({
|
||||
...item,
|
||||
total: parseFloat(item.total.toString()),
|
||||
date: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading chart...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">Error loading sales overview</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={data}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `$${value.toLocaleString()}`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales']}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="total"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,16 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { ClipboardList, AlertCircle, Truck, DollarSign, Tag } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
interface PurchaseMetricsData {
|
||||
activePurchaseOrders: number // Orders that are not canceled, done, or fully received
|
||||
overduePurchaseOrders: number // Orders past their expected delivery date
|
||||
onOrderUnits: number // Total units across all active orders
|
||||
onOrderCost: number // Total cost across all active orders
|
||||
onOrderRetail: number // Total retail value across all active orders
|
||||
activePurchaseOrders: number
|
||||
overduePurchaseOrders: number
|
||||
onOrderUnits: number
|
||||
onOrderCost: number
|
||||
onOrderRetail: number
|
||||
vendorOrders: {
|
||||
vendor: string
|
||||
orders: number
|
||||
@@ -35,7 +35,6 @@ const COLORS = [
|
||||
const renderActiveShape = (props: any) => {
|
||||
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, vendor, cost } = props;
|
||||
|
||||
// Split vendor name into words and create lines of max 12 chars
|
||||
const words = vendor.split(' ');
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
@@ -52,150 +51,135 @@ const renderActiveShape = (props: any) => {
|
||||
|
||||
return (
|
||||
<g>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
fill={fill}
|
||||
/>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={outerRadius - 1}
|
||||
outerRadius={outerRadius + 4}
|
||||
fill={fill}
|
||||
/>
|
||||
<Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill} />
|
||||
<Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius - 1} outerRadius={outerRadius + 4} fill={fill} />
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={-20 + (i * 16)}
|
||||
textAnchor="middle"
|
||||
fill="#888888"
|
||||
className="text-xs"
|
||||
>
|
||||
<text key={i} x={cx} y={cy} dy={-20 + (i * 16)} textAnchor="middle" fill="#888888" className="text-xs">
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={lines.length * 16 - 10}
|
||||
textAnchor="middle"
|
||||
fill="#000000"
|
||||
className="text-base font-medium"
|
||||
>
|
||||
<text x={cx} y={cy} dy={lines.length * 16 - 10} textAnchor="middle" fill="#000000" className="text-base font-medium">
|
||||
{formatCurrency(cost)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
export function PurchaseMetrics() {
|
||||
const [activeIndex, setActiveIndex] = useState<number | undefined>();
|
||||
|
||||
const { data, error, isLoading } = useQuery<PurchaseMetricsData>({
|
||||
const { data, isError, isLoading } = useQuery<PurchaseMetricsData>({
|
||||
queryKey: ["purchase-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/purchase/metrics`)
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('API Error:', text);
|
||||
throw new Error(`Failed to fetch purchase metrics: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
if (!response.ok) throw new Error('Failed to fetch purchase metrics');
|
||||
return response.json();
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error loading purchase metrics</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">Purchases</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between gap-8">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Active Purchase Orders</p>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load purchase metrics</p>
|
||||
) : (
|
||||
<div className="flex gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Active Purchase Orders</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.activePurchaseOrders.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.overduePurchaseOrders.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Truck className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">On Order Units</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.onOrderUnits.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">On Order Cost</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.onOrderCost)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Tag className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">On Order Retail</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.onOrderRetail)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.activePurchaseOrders.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">PO Costs By Vendor</div>
|
||||
<div className="h-[180px]">
|
||||
{isLoading || !data ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-[160px] w-[160px] animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.vendorOrders}
|
||||
dataKey="cost"
|
||||
nameKey="vendor"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
activeIndex={activeIndex}
|
||||
activeShape={renderActiveShape}
|
||||
onMouseEnter={(_, index) => setActiveIndex(index)}
|
||||
onMouseLeave={() => setActiveIndex(undefined)}
|
||||
>
|
||||
{data.vendorOrders.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.vendor}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.overduePurchaseOrders.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">On Order Units</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.onOrderUnits.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">On Order Cost</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(data?.onOrderCost || 0)}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">On Order Retail</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(data?.onOrderRetail || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">Purchase Orders By Vendor</div>
|
||||
<div className="h-[180px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data?.vendorOrders || []}
|
||||
dataKey="cost"
|
||||
nameKey="vendor"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
activeIndex={activeIndex}
|
||||
activeShape={renderActiveShape}
|
||||
onMouseEnter={(_, index) => setActiveIndex(index)}
|
||||
onMouseLeave={() => setActiveIndex(undefined)}
|
||||
>
|
||||
{data?.vendorOrders?.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.vendor}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { Package, DollarSign, ShoppingCart } from "lucide-react" // Importing icons
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { PackagePlus, DollarSign, Tag } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { PHASE_CONFIG } from "@/utils/lifecyclePhases"
|
||||
|
||||
interface PhaseBreakdown {
|
||||
phase: string
|
||||
products: number
|
||||
units: number
|
||||
cost: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
interface ReplenishmentMetricsData {
|
||||
productsToReplenish: number
|
||||
unitsToReplenish: number
|
||||
replenishmentCost: number
|
||||
replenishmentRetail: number
|
||||
phaseBreakdown?: PhaseBreakdown[]
|
||||
topVariants: {
|
||||
id: number
|
||||
title: string
|
||||
@@ -21,54 +32,94 @@ interface ReplenishmentMetricsData {
|
||||
}[]
|
||||
}
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
export function ReplenishmentMetrics() {
|
||||
const { data, error, isLoading } = useQuery<ReplenishmentMetricsData>({
|
||||
const { data, isError, isLoading } = useQuery<ReplenishmentMetricsData>({
|
||||
queryKey: ["replenishment-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`)
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('API Error:', text);
|
||||
throw new Error(`Failed to fetch replenishment metrics: ${response.status} ${response.statusText} - ${text}`)
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
if (!response.ok) throw new Error('Failed to fetch replenishment metrics');
|
||||
return response.json();
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="p-8 text-center">Loading replenishment metrics...</div>;
|
||||
if (error) return <div className="p-8 text-center text-red-500">Error: {error.message}</div>;
|
||||
if (!data) return <div className="p-8 text-center">No replenishment data available</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">Replenishment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Units to Replenish</p>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load replenishment metrics</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<PackagePlus className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Units to Replenish</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.unitsToReplenish.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data.unitsToReplenish.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Replenishment Cost</p>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Replenishment Cost</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.replenishmentCost)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(data.replenishmentCost || 0)}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Replenishment Retail</p>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Replenishment Retail</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.replenishmentRetail)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(data.replenishmentRetail || 0)}</p>
|
||||
{data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (
|
||||
<div className="mt-1 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Replenishment Cost By Lifecycle Phase</p>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="flex h-2.5 w-full overflow-hidden rounded-full">
|
||||
{data.phaseBreakdown.map((p) => {
|
||||
const cfg = PHASE_CONFIG[p.phase] || { label: p.phase, color: "#94A3B8" }
|
||||
return (
|
||||
<Tooltip key={p.phase}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${p.percentage}%`,
|
||||
backgroundColor: cfg.color,
|
||||
minWidth: p.percentage > 0 ? 3 : 0,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<div className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: cfg.color }} />
|
||||
{cfg.label}
|
||||
<span className="font-normal opacity-70">{p.percentage}%</span>
|
||||
</div>
|
||||
<div className="mt-0.5 font-semibold">{formatCurrency(p.cost)}</div>
|
||||
<div className="opacity-70">{p.products} products · {p.units} units</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,126 +1,228 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
|
||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts"
|
||||
import { useState } from "react"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react"
|
||||
import { DateRange } from "react-day-picker"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { addDays, format } from "date-fns"
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
||||
import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases"
|
||||
|
||||
type Period = 7 | 30 | 90;
|
||||
|
||||
interface PhaseBreakdown {
|
||||
phase: string
|
||||
orders: number
|
||||
units: number
|
||||
revenue: number
|
||||
cogs: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
interface DailyPhaseData {
|
||||
date: string
|
||||
preorder: number
|
||||
launch: number
|
||||
decay: number
|
||||
mature: number
|
||||
slow_mover: number
|
||||
dormant: number
|
||||
unknown: number
|
||||
}
|
||||
|
||||
interface SalesData {
|
||||
totalOrders: number
|
||||
totalUnitsSold: number
|
||||
totalCogs: string
|
||||
totalRevenue: string
|
||||
totalCogs: number
|
||||
totalRevenue: number
|
||||
dailySales: {
|
||||
date: string
|
||||
units: number
|
||||
revenue: string
|
||||
cogs: string
|
||||
revenue: number
|
||||
cogs: number
|
||||
}[]
|
||||
dailySalesByPhase?: DailyPhaseData[]
|
||||
phaseBreakdown?: PhaseBreakdown[]
|
||||
}
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
export function SalesMetrics() {
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
from: addDays(new Date(), -30),
|
||||
to: new Date(),
|
||||
});
|
||||
const [period, setPeriod] = useState<Period>(30);
|
||||
|
||||
const { data } = useQuery<SalesData>({
|
||||
queryKey: ["sales-metrics", dateRange],
|
||||
const { data, isError, isLoading } = useQuery<SalesData>({
|
||||
queryKey: ["sales-metrics", period],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
startDate: dateRange.from?.toISOString() || "",
|
||||
endDate: dateRange.to?.toISOString() || "",
|
||||
startDate: addDays(new Date(), -period).toISOString(),
|
||||
endDate: new Date().toISOString(),
|
||||
});
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch sales metrics")
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to fetch sales metrics");
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
|
||||
const hasPhaseData = data?.dailySalesByPhase && data.dailySalesByPhase.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
||||
<CardTitle className="text-xl font-medium">Sales</CardTitle>
|
||||
<div className="w-[230px]">
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={(range) => {
|
||||
if (range) setDateRange(range);
|
||||
}}
|
||||
future={false}
|
||||
/>
|
||||
</div>
|
||||
<Tabs value={String(period)} onValueChange={(v) => setPeriod(Number(v) as Period)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="7">7D</TabsTrigger>
|
||||
<TabsTrigger value="30">30D</TabsTrigger>
|
||||
<TabsTrigger value="90">90D</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
<CardContent className="py-0 -mb-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Orders</p>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load sales metrics</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Orders</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.totalOrders.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Units Sold</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.totalUnitsSold.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data.totalCogs))}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data.totalRevenue))}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.totalOrders.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Units Sold</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.totalUnitsSold.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalCogs) || 0)}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalRevenue) || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[250px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data?.dailySales || []}
|
||||
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
|
||||
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#00C49F"
|
||||
fill="#00C49F"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Revenue By Lifecycle Phase</p>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="flex h-2.5 w-full overflow-hidden rounded-full">
|
||||
{data.phaseBreakdown.map((p) => {
|
||||
const cfg = PHASE_CONFIG[p.phase] || { label: p.phase, color: "#94A3B8" }
|
||||
return (
|
||||
<Tooltip key={p.phase}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${p.percentage}%`,
|
||||
backgroundColor: cfg.color,
|
||||
minWidth: p.percentage > 0 ? 3 : 0,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<div className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: cfg.color }} />
|
||||
{cfg.label}
|
||||
<span className="font-normal opacity-70">{p.percentage}%</span>
|
||||
</div>
|
||||
<div className="mt-0.5 font-semibold">{formatCurrency(p.revenue)}</div>
|
||||
<div className="opacity-70">{p.units.toLocaleString()} units · {p.orders.toLocaleString()} orders</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-[250px] w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-[200px] w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={hasPhaseData ? data.dailySalesByPhase : (data?.dailySales || [])}
|
||||
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
const cfg = PHASE_CONFIG[name]
|
||||
return [formatCurrency(value), cfg?.label || name]
|
||||
}}
|
||||
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||
itemSorter={(item) => -(item.value as number || 0)}
|
||||
/>
|
||||
{hasPhaseData ? (
|
||||
PHASE_KEYS.map((phase) => {
|
||||
const cfg = PHASE_CONFIG[phase]
|
||||
return (
|
||||
<Area
|
||||
key={phase}
|
||||
type="monotone"
|
||||
dataKey={phase}
|
||||
name={phase}
|
||||
stackId="a"
|
||||
stroke={cfg.color}
|
||||
fill={cfg.color}
|
||||
fillOpacity={0.6}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#00C49F"
|
||||
fill="#00C49F"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -2,23 +2,34 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { Package, PackageCheck, Layers, DollarSign, Tag } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { PHASE_CONFIG } from "@/utils/lifecyclePhases"
|
||||
|
||||
interface PhaseStock {
|
||||
phase: string
|
||||
products: number
|
||||
units: number
|
||||
cost: number
|
||||
retail: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
interface StockMetricsData {
|
||||
totalProducts: number
|
||||
productsInStock: number
|
||||
totalStockUnits: number
|
||||
totalStockCost: string
|
||||
totalStockRetail: string
|
||||
totalStockCost: number
|
||||
totalStockRetail: number
|
||||
brandStock: {
|
||||
brand: string
|
||||
variants: number
|
||||
units: number
|
||||
cost: string
|
||||
retail: string
|
||||
cost: number
|
||||
retail: number
|
||||
}[]
|
||||
phaseStock?: PhaseStock[]
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
@@ -32,170 +43,211 @@ const COLORS = [
|
||||
"#FF7C43",
|
||||
]
|
||||
|
||||
const renderActiveShape = (props: any) => {
|
||||
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, brand, retail } = props;
|
||||
|
||||
// Split brand name into words and create lines of max 12 chars
|
||||
const words = brand.split(' ');
|
||||
function wrapLabel(text: string, maxLen = 12): string[] {
|
||||
const words = text.split(' ');
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
|
||||
let cur = '';
|
||||
words.forEach((word: string) => {
|
||||
if ((currentLine + ' ' + word).length <= 12) {
|
||||
currentLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
if ((cur + ' ' + word).length <= maxLen) {
|
||||
cur = cur ? `${cur} ${word}` : word;
|
||||
} else {
|
||||
if (currentLine) lines.push(currentLine);
|
||||
currentLine = word;
|
||||
if (cur) lines.push(cur);
|
||||
cur = word;
|
||||
}
|
||||
});
|
||||
if (currentLine) lines.push(currentLine);
|
||||
if (cur) lines.push(cur);
|
||||
return lines;
|
||||
}
|
||||
|
||||
const renderActiveShape = (props: any) => {
|
||||
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, brand, cost } = props;
|
||||
const lines = wrapLabel(brand);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
fill={fill}
|
||||
/>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={outerRadius - 1}
|
||||
outerRadius={outerRadius + 4}
|
||||
fill={fill}
|
||||
/>
|
||||
<Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill} />
|
||||
<Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius - 1} outerRadius={outerRadius + 4} fill={fill} />
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={-20 + (i * 16)}
|
||||
textAnchor="middle"
|
||||
fill="#888888"
|
||||
className="text-xs"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
<text key={i} x={cx} y={cy} dy={-20 + (i * 16)} textAnchor="middle" fill="#888888" className="text-xs">{line}</text>
|
||||
))}
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={lines.length * 16 - 10}
|
||||
textAnchor="middle"
|
||||
fill="#000000"
|
||||
className="text-base font-medium"
|
||||
>
|
||||
{formatCurrency(Number(retail))}
|
||||
<text x={cx} y={cy} dy={lines.length * 16 - 10} textAnchor="middle" fill="#000000" className="text-base font-medium">
|
||||
{formatCurrency(cost)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPhaseActiveShape = (props: any) => {
|
||||
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, phase, cost } = props;
|
||||
const cfg = PHASE_CONFIG[phase] || { label: phase };
|
||||
const lines = wrapLabel(cfg.label);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill} />
|
||||
<Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius - 1} outerRadius={outerRadius + 4} fill={fill} />
|
||||
{lines.map((line, i) => (
|
||||
<text key={i} x={cx} y={cy} dy={-20 + (i * 16)} textAnchor="middle" fill="#888888" className="text-xs">{line}</text>
|
||||
))}
|
||||
<text x={cx} y={cy} dy={lines.length * 16 - 10} textAnchor="middle" fill="#000000" className="text-base font-medium">
|
||||
{formatCurrency(cost)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
export function StockMetrics() {
|
||||
const [activeIndex, setActiveIndex] = useState<number | undefined>();
|
||||
const [activePhaseIndex, setActivePhaseIndex] = useState<number | undefined>();
|
||||
|
||||
const { data, error, isLoading } = useQuery<StockMetricsData>({
|
||||
const { data, isError, isLoading } = useQuery<StockMetricsData>({
|
||||
queryKey: ["stock-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('API Error:', text);
|
||||
throw new Error(`Failed to fetch stock metrics: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
if (!response.ok) throw new Error('Failed to fetch stock metrics');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error loading stock metrics</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">Stock</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between gap-8">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Products</p>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load stock metrics</p>
|
||||
) : (
|
||||
<div className="flex gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Package className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Products</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.totalProducts.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<PackageCheck className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Products In Stock</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.productsInStock.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Layers className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Stock Units</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.totalStockUnits.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Stock Cost</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.totalStockCost)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Tag className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Stock Retail</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.totalStockRetail)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.totalProducts.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Products In Stock</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">Stock Cost By Brand</div>
|
||||
<div className="h-[180px]">
|
||||
{isLoading || !data ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-[160px] w-[160px] animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.brandStock}
|
||||
dataKey="cost"
|
||||
nameKey="brand"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
activeIndex={activeIndex}
|
||||
activeShape={renderActiveShape}
|
||||
onMouseEnter={(_, index) => setActiveIndex(index)}
|
||||
onMouseLeave={() => setActiveIndex(undefined)}
|
||||
>
|
||||
{data.brandStock.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.brand}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.productsInStock.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Stock Units</p>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">Stock Cost By Phase</div>
|
||||
<div className="h-[180px]">
|
||||
{isLoading || !data?.phaseStock ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-[160px] w-[160px] animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.phaseStock}
|
||||
dataKey="cost"
|
||||
nameKey="phase"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
activeIndex={activePhaseIndex}
|
||||
activeShape={renderPhaseActiveShape}
|
||||
onMouseEnter={(_, index) => setActivePhaseIndex(index)}
|
||||
onMouseLeave={() => setActivePhaseIndex(undefined)}
|
||||
>
|
||||
{data.phaseStock.map((entry) => {
|
||||
const cfg = PHASE_CONFIG[entry.phase] || { color: "#94A3B8" }
|
||||
return (
|
||||
<Cell key={entry.phase} fill={cfg.color} />
|
||||
)
|
||||
})}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.totalStockUnits.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Stock Cost</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockCost) || 0)}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Stock Retail</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockRetail) || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">Stock Retail By Brand</div>
|
||||
<div className="h-[180px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data?.brandStock || []}
|
||||
dataKey="retail"
|
||||
nameKey="brand"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
activeIndex={activeIndex}
|
||||
activeShape={renderActiveShape}
|
||||
onMouseEnter={(_, index) => setActiveIndex(index)}
|
||||
onMouseLeave={() => setActiveIndex(undefined)}
|
||||
>
|
||||
{data?.brandStock?.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.brand}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
|
||||
interface Product {
|
||||
pid: number;
|
||||
@@ -15,14 +15,22 @@ interface Product {
|
||||
excess_retail: number;
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopOverstockedProducts() {
|
||||
const { data } = useQuery<Product[]>({
|
||||
const { data, isError, isLoading } = useQuery<Product[]>({
|
||||
queryKey: ["top-overstocked-products"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch overstocked products")
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to fetch overstocked products");
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
@@ -33,40 +41,46 @@ export function TopOverstockedProducts() {
|
||||
<CardTitle className="text-xl font-medium">Top Overstocked Products</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[300px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Excess</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Retail</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||
<TableCell className="text-right">{product.overstocked_amt}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(product.excess_cost)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(product.excess_retail)}</TableCell>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load overstocked products</p>
|
||||
) : isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<ScrollArea className="h-[300px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Excess</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Retail</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||
<TableCell className="text-right">{product.overstocked_amt}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(product.excess_cost))}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(product.excess_retail))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import config from "@/config"
|
||||
import { format } from "date-fns"
|
||||
|
||||
interface Product {
|
||||
pid: number;
|
||||
@@ -14,14 +15,22 @@ interface Product {
|
||||
last_purchase_date: string | null;
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopReplenishProducts() {
|
||||
const { data } = useQuery<Product[]>({
|
||||
const { data, isError, isLoading } = useQuery<Product[]>({
|
||||
queryKey: ["top-replenish-products"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch products to replenish")
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to fetch products to replenish");
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
@@ -32,40 +41,46 @@ export function TopReplenishProducts() {
|
||||
<CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="max-h-[530px] w-full overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Daily Sales</TableHead>
|
||||
<TableHead className="text-right">Reorder Qty</TableHead>
|
||||
<TableHead>Last Purchase</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||
<TableCell className="text-right">{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
|
||||
<TableCell className="text-right">{product.reorder_qty}</TableCell>
|
||||
<TableCell>{product.last_purchase_date ? product.last_purchase_date : '-'}</TableCell>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load replenish products</p>
|
||||
) : isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<ScrollArea className="max-h-[630px] w-full overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Daily Sales</TableHead>
|
||||
<TableHead className="text-right">Reorder Qty</TableHead>
|
||||
<TableHead>Last Purchase</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||
<TableCell className="text-right">{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
|
||||
<TableCell className="text-right">{product.reorder_qty}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{product.last_purchase_date ? format(new Date(product.last_purchase_date), 'M/dd/yyyy') : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import config from "@/config"
|
||||
|
||||
interface VendorMetrics {
|
||||
vendor: string
|
||||
avg_lead_time: number
|
||||
on_time_delivery_rate: number
|
||||
avg_fill_rate: number
|
||||
total_orders: number
|
||||
active_orders: number
|
||||
overdue_orders: number
|
||||
}
|
||||
|
||||
export function VendorPerformance() {
|
||||
const { data: vendors } = useQuery<VendorMetrics[]>({
|
||||
queryKey: ["vendor-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/vendor/performance`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch vendor metrics")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
|
||||
// Sort vendors by on-time delivery rate
|
||||
const sortedVendors = vendors
|
||||
?.sort((a, b) => b.on_time_delivery_rate - a.on_time_delivery_rate)
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium">Top Vendor Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[400px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>On-Time</TableHead>
|
||||
<TableHead className="text-right">Fill Rate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedVendors?.map((vendor) => (
|
||||
<TableRow key={vendor.vendor}>
|
||||
<TableCell className="font-medium">{vendor.vendor}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={vendor.on_time_delivery_rate}
|
||||
className="h-2"
|
||||
/>
|
||||
<span className="w-10 text-sm">
|
||||
{vendor.on_time_delivery_rate.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{vendor.avg_fill_rate.toFixed(0)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function EditableInput({
|
||||
copyable,
|
||||
alwaysShowCopy,
|
||||
formatDisplay,
|
||||
rightAction,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
@@ -30,6 +31,7 @@ export function EditableInput({
|
||||
copyable?: boolean;
|
||||
alwaysShowCopy?: boolean;
|
||||
formatDisplay?: (val: string) => string;
|
||||
rightAction?: React.ReactNode;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -106,6 +108,7 @@ export function EditableInput({
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{rightAction}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useState, useMemo, useCallback, useLayoutEffect, useRef } from "react";
|
||||
import { Check, ChevronsUpDown, Sparkles, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { badgeVariants } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FieldOption } from "./types";
|
||||
import type { TaxonomySuggestion } from "@/components/product-import/steps/ValidationStep/store/types";
|
||||
|
||||
interface ColorOption extends FieldOption {
|
||||
hex?: string;
|
||||
@@ -34,6 +36,39 @@ function isWhite(hex: string) {
|
||||
return /^#?f{3,6}$/i.test(hex);
|
||||
}
|
||||
|
||||
function TruncatedBadge({ label, hex }: { label: string; hex?: string }) {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = textRef.current;
|
||||
if (el) setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<Tooltip open={isTruncated ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(badgeVariants({ variant: "secondary" }), "text-[11px] py-0 px-1.5 gap-1 font-normal max-w-full")}>
|
||||
{hex && (
|
||||
<span
|
||||
className={cn("inline-block h-2.5 w-2.5 rounded-full shrink-0", isWhite(hex) && "border border-black")}
|
||||
style={{ backgroundColor: hex }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
ref={textRef}
|
||||
className="overflow-hidden whitespace-nowrap"
|
||||
style={{ direction: "rtl", textOverflow: "ellipsis" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableMultiSelect({
|
||||
options,
|
||||
value,
|
||||
@@ -42,6 +77,9 @@ export function EditableMultiSelect({
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
showColors,
|
||||
suggestions,
|
||||
isLoadingSuggestions,
|
||||
onOpen,
|
||||
}: {
|
||||
options: FieldOption[];
|
||||
value: string[];
|
||||
@@ -50,9 +88,17 @@ export function EditableMultiSelect({
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
showColors?: boolean;
|
||||
suggestions?: TaxonomySuggestion[];
|
||||
isLoadingSuggestions?: boolean;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
if (isOpen) onOpen?.();
|
||||
}, [onOpen]);
|
||||
|
||||
const selectedLabels = useMemo(() => {
|
||||
return value.map((v) => {
|
||||
const opt = options.find((o) => String(o.value) === String(v));
|
||||
@@ -82,7 +128,7 @@ export function EditableMultiSelect({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
className={cn(
|
||||
"flex flex-col h-auto w-full rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
||||
"hover:border-input hover:bg-muted/50 transition-colors",
|
||||
@@ -98,22 +144,7 @@ export function EditableMultiSelect({
|
||||
) : (
|
||||
<span className="flex flex-wrap gap-1 w-full">
|
||||
{selectedLabels.map((s) => (
|
||||
<Badge
|
||||
key={s.value}
|
||||
variant="secondary"
|
||||
className="text-[11px] py-0 px-1.5 gap-1 shrink-0 font-normal"
|
||||
>
|
||||
{s.hex && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2.5 w-2.5 rounded-full shrink-0",
|
||||
isWhite(s.hex) && "border border-black"
|
||||
)}
|
||||
style={{ backgroundColor: s.hex }}
|
||||
/>
|
||||
)}
|
||||
{s.label}
|
||||
</Badge>
|
||||
<TruncatedBadge key={s.value} label={s.label} hex={s.hex} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -126,7 +157,7 @@ export function EditableMultiSelect({
|
||||
{label && (
|
||||
<span className="text-xs text-muted-foreground mb-1 block">{label}</span>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -192,9 +223,54 @@ export function EditableMultiSelect({
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* AI Suggestions section */}
|
||||
{(suggestions && suggestions.length > 0) || isLoadingSuggestions ? (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>Suggested</span>
|
||||
{isLoadingSuggestions && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
</div>
|
||||
{suggestions?.slice(0, 5).map((suggestion) => {
|
||||
const isSelected = value.includes(String(suggestion.id));
|
||||
if (isSelected) return null;
|
||||
const similarityPercent = Math.round(suggestion.similarity * 100);
|
||||
const opt = options.find((o) => String(o.value) === String(suggestion.id)) as ColorOption | undefined;
|
||||
const hex = showColors && opt ? getHex(opt) : undefined;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`suggestion-${suggestion.id}`}
|
||||
value={`suggestion-${suggestion.name}`}
|
||||
onSelect={() => handleSelect(String(suggestion.id))}
|
||||
className="bg-purple-50/30 dark:bg-purple-950/20"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Check className="h-4 w-4 flex-shrink-0 opacity-0" />
|
||||
{hex && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full mr-2 shrink-0",
|
||||
isWhite(hex) && "border border-black"
|
||||
)}
|
||||
style={{ backgroundColor: hex }}
|
||||
/>
|
||||
)}
|
||||
<span title={suggestion.fullPath || suggestion.name}>
|
||||
{showColors ? suggestion.name : (suggestion.fullPath || suggestion.name)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-purple-500 dark:text-purple-400 ml-2 flex-shrink-0">
|
||||
{similarityPercent}%
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
|
||||
{/* All options (excluding already-selected) */}
|
||||
<CommandGroup
|
||||
heading={value.length > 0 ? "All Options" : undefined}
|
||||
heading={value.length > 0 || (suggestions && suggestions.length > 0) ? "All Options" : undefined}
|
||||
>
|
||||
{options
|
||||
.filter((o) => !value.includes(String(o.value)))
|
||||
|
||||
@@ -175,7 +175,7 @@ function SortableImageCell({
|
||||
src={src}
|
||||
alt={`Image ${image.iid}`}
|
||||
className={cn(
|
||||
"w-full h-full object-cover pointer-events-none select-none",
|
||||
"w-full h-full object-contain pointer-events-none select-none",
|
||||
isMain ? "rounded-lg" : "rounded-md"
|
||||
)}
|
||||
draggable={false}
|
||||
|
||||
@@ -7,11 +7,16 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink } from "lucide-react";
|
||||
import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
|
||||
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink, Sparkles } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useInlineAiValidation } from "@/components/product-import/steps/ValidationStep/hooks/useInlineAiValidation";
|
||||
import { AiSuggestionBadge } from "@/components/product-import/steps/ValidationStep/components/AiSuggestionBadge";
|
||||
import { AiDescriptionCompare } from "@/components/ai/AiDescriptionCompare";
|
||||
import { submitProductEdit, submitImageChanges, submitTaxonomySet, type ImageChanges } from "@/services/productEditor";
|
||||
import { EditableComboboxField } from "./EditableComboboxField";
|
||||
import { EditableInput } from "./EditableInput";
|
||||
import { EditableMultiSelect } from "./EditableMultiSelect";
|
||||
import { useProductSuggestions } from "./useProductSuggestions";
|
||||
import { ImageManager, MiniImagePreview } from "./ImageManager";
|
||||
import type {
|
||||
SearchProduct,
|
||||
@@ -75,6 +80,8 @@ interface FieldConfig {
|
||||
showColors?: boolean;
|
||||
/** Format value for display (editing shows raw value) */
|
||||
formatDisplay?: (val: string) => string;
|
||||
/** Number of grid columns this field spans (default 1) */
|
||||
colSpan?: number;
|
||||
}
|
||||
|
||||
interface FieldGroup {
|
||||
@@ -103,7 +110,8 @@ const F: Record<string, FieldConfig> = {
|
||||
artist: { key: "artist", label: "Artist", type: "combobox", optionsKey: "artists", searchPlaceholder: "Search artists..." },
|
||||
tax_cat: { key: "tax_cat", label: "Tax Cat", type: "combobox", optionsKey: "taxCategories" },
|
||||
ship: { key: "ship_restrictions", label: "Shipping", type: "combobox", optionsKey: "shippingRestrictions" },
|
||||
msrp: { key: "msrp", label: "MSRP", type: "input" },
|
||||
msrp: { key: "msrp", label: "MSRP", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
|
||||
cur_price: { key: "current_price", label: "Current", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
|
||||
cost: { key: "cost_each", label: "Cost", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
|
||||
min_qty: { key: "qty_per_unit", label: "Min Qty", type: "input" },
|
||||
case_qty: { key: "case_qty", label: "Case Pack", type: "input" },
|
||||
@@ -141,7 +149,7 @@ const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
|
||||
{ label: "Taxonomy", cols: 2, fields: [F.supplier,F.company, F.line, F.subline] },
|
||||
{ cols: 2, fields: [F.artist, F.size_cat] },
|
||||
{ label: "Description", cols: 1, fields: [F.description] },
|
||||
{ label: "Pricing", cols: 4, fields: [F.msrp, F.cost, F.min_qty, F.case_qty] },
|
||||
{ label: "Pricing", cols: 5, fields: [F.msrp, F.cur_price, F.cost, F.min_qty, F.case_qty] },
|
||||
{ label: "Dimensions", cols: 4, fields: [F.weight, F.length, F.width, F.height] },
|
||||
{ cols: 4, fields: [ F.tax_cat, F.ship,F.coo, F.hts_code] },
|
||||
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
|
||||
@@ -152,22 +160,21 @@ const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
|
||||
sidebarGroups: 3,
|
||||
descriptionRows: 8,
|
||||
groups: [
|
||||
{ label: "Taxonomy", cols: 2, fields: [F.company, F.msrp, F.line, F.subline] },
|
||||
{ cols: 2, fields: [F.artist, F.size_cat] },
|
||||
{ label: "Taxonomy", cols: 7, fields: [{ ...F.company, colSpan: 3 }, { ...F.msrp, colSpan: 2 }, { ...F.cur_price, colSpan: 2 }] },
|
||||
{ cols: 2, fields: [F.line, F.subline, F.artist, F.size_cat] },
|
||||
{ label: "Description", cols: 1, fields: [F.description] },
|
||||
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
|
||||
|
||||
],
|
||||
},
|
||||
backend: {
|
||||
sidebarGroups: 5,
|
||||
sidebarGroups: 6,
|
||||
groups: [
|
||||
{ label: "Pricing", cols: 2, fields: [F.supplier, F.min_qty, F.cost, F.msrp] },
|
||||
{ label: "Pricing", cols: 2, fields: [F.supplier, F.min_qty] },
|
||||
{ cols: 3, fields: [F.cost, F.cur_price, F.msrp] },
|
||||
{ cols: 3, fields: [F.case_qty, F.size_cat, F.weight] },
|
||||
{ label: "Dimensions", cols: 3, fields: [ F.length, F.width, F.height] },
|
||||
{ label: "Dimensions", cols: 3, fields: [F.length, F.width, F.height] },
|
||||
{ cols: 2, fields: [F.tax_cat, F.ship, F.coo, F.hts_code] },
|
||||
{ label: "Notes", cols: 1, fields: [F.priv_notes] },
|
||||
|
||||
],
|
||||
},
|
||||
minimal: {
|
||||
@@ -207,11 +214,14 @@ export function ProductEditForm({
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { dirtyFields },
|
||||
} = useForm<ProductFormValues>();
|
||||
|
||||
const watchCompany = watch("company");
|
||||
const watchLine = watch("line");
|
||||
const watchDescription = watch("description");
|
||||
|
||||
// Populate form on mount
|
||||
useEffect(() => {
|
||||
@@ -226,6 +236,7 @@ export function ProductEditForm({
|
||||
supplier_no: product.vendor_reference ?? "",
|
||||
notions_no: product.notions_reference ?? "",
|
||||
msrp: String(product.regular_price ?? ""),
|
||||
current_price: String(product.price ?? ""),
|
||||
cost_each: String(product.cost_price ?? ""),
|
||||
qty_per_unit: String(product.moq ?? ""),
|
||||
case_qty: String(product.case_qty ?? ""),
|
||||
@@ -317,12 +328,13 @@ export function ProductEditForm({
|
||||
const originalIds = original.map((img) => img.iid);
|
||||
const currentIds = current.map((img) => img.iid);
|
||||
|
||||
const deleted = originalIds.filter((id) => !currentIds.includes(id)) as number[];
|
||||
const toDelete = originalIds.filter((id) => !currentIds.includes(id)) as number[];
|
||||
const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
|
||||
const added: Record<string, string> = {};
|
||||
const show = current.filter((img) => !img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
|
||||
const add: Record<string, string> = {};
|
||||
for (const img of current) {
|
||||
if (img.isNew && img.imageUrl) {
|
||||
added[String(img.iid)] = img.imageUrl;
|
||||
add[String(img.iid)] = img.imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,14 +343,14 @@ export function ProductEditForm({
|
||||
const originalHidden = original.filter((img) => img.hidden).map((img) => img.iid);
|
||||
const orderChanged = JSON.stringify(order.filter((id) => typeof id === "number")) !== JSON.stringify(originalIds);
|
||||
const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort());
|
||||
const hasDeleted = deleted.length > 0;
|
||||
const hasAdded = Object.keys(added).length > 0;
|
||||
const hasDeleted = toDelete.length > 0;
|
||||
const hasAdded = Object.keys(add).length > 0;
|
||||
|
||||
if (!orderChanged && !hiddenChanged && !hasDeleted && !hasAdded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { order, hidden, deleted, added };
|
||||
return { order, hidden, show, delete: toDelete, add };
|
||||
}, [productImages]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
@@ -362,32 +374,57 @@ export function ProductEditForm({
|
||||
|
||||
const imageChanges = computeImageChanges();
|
||||
|
||||
if (Object.keys(changes).length === 0 && !imageChanges) {
|
||||
// Extract taxonomy changes for separate API calls
|
||||
const taxonomyCalls: { type: "cats" | "themes" | "colors"; ids: number[] }[] = [];
|
||||
if ("categories" in changes) {
|
||||
taxonomyCalls.push({ type: "cats", ids: (changes.categories as string[]).map(Number) });
|
||||
delete changes.categories;
|
||||
}
|
||||
if ("themes" in changes) {
|
||||
taxonomyCalls.push({ type: "themes", ids: (changes.themes as string[]).map(Number) });
|
||||
delete changes.themes;
|
||||
}
|
||||
if ("colors" in changes) {
|
||||
taxonomyCalls.push({ type: "colors", ids: (changes.colors as string[]).map(Number) });
|
||||
delete changes.colors;
|
||||
}
|
||||
|
||||
const hasFieldChanges = Object.keys(changes).length > 0;
|
||||
|
||||
if (!hasFieldChanges && !imageChanges && taxonomyCalls.length === 0) {
|
||||
toast.info("No changes to submit");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await submitProductEdit({
|
||||
pid: product.pid,
|
||||
changes,
|
||||
environment: "prod",
|
||||
imageChanges: imageChanges ?? undefined,
|
||||
});
|
||||
const promises: Promise<{ success: boolean; error?: unknown; message?: string }>[] = [];
|
||||
|
||||
if (result.success) {
|
||||
if (hasFieldChanges) {
|
||||
promises.push(submitProductEdit({ pid: product.pid, changes, environment: "prod" }));
|
||||
}
|
||||
if (imageChanges) {
|
||||
promises.push(submitImageChanges({ pid: product.pid, imageChanges, environment: "prod" }));
|
||||
}
|
||||
for (const { type, ids } of taxonomyCalls) {
|
||||
promises.push(submitTaxonomySet({ pid: product.pid, type, ids, environment: "prod" }));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const failed = results.find((r) => !r.success);
|
||||
|
||||
if (failed) {
|
||||
const errorDetail = Array.isArray(failed.error)
|
||||
? failed.error.filter((e) => e !== "Errors").join("; ")
|
||||
: typeof failed.error === "string"
|
||||
? failed.error
|
||||
: null;
|
||||
toast.error(errorDetail || failed.message || "Failed to update product");
|
||||
} else {
|
||||
toast.success("Product updated successfully");
|
||||
originalValuesRef.current = { ...data };
|
||||
originalImagesRef.current = [...productImages];
|
||||
reset(data);
|
||||
} else {
|
||||
const errorDetail = Array.isArray(result.error)
|
||||
? result.error.filter((e) => e !== "Errors").join("; ")
|
||||
: typeof result.error === "string"
|
||||
? result.error
|
||||
: null;
|
||||
toast.error(errorDetail || result.message || "Failed to update product");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
@@ -411,6 +448,76 @@ export function ProductEditForm({
|
||||
[fieldOptions, lineOptions, sublineOptions]
|
||||
);
|
||||
|
||||
// --- AI inline validation ---
|
||||
const [validatingField, setValidatingField] = useState<"name" | "description" | null>(null);
|
||||
const [descDialogOpen, setDescDialogOpen] = useState(false);
|
||||
const {
|
||||
validateName,
|
||||
validateDescription,
|
||||
nameResult,
|
||||
descriptionResult,
|
||||
clearNameResult,
|
||||
clearDescriptionResult,
|
||||
} = useInlineAiValidation();
|
||||
|
||||
const handleValidateName = useCallback(async () => {
|
||||
const values = getValues();
|
||||
if (!values.name?.trim()) return;
|
||||
clearNameResult();
|
||||
setValidatingField("name");
|
||||
const companyLabel = fieldOptions.companies.find((c) => c.value === values.company)?.label;
|
||||
const lineLabel = lineOptions.find((l) => l.value === values.line)?.label;
|
||||
const sublineLabel = sublineOptions.find((s) => s.value === values.subline)?.label;
|
||||
const result = await validateName({
|
||||
name: values.name,
|
||||
company_name: companyLabel,
|
||||
company_id: values.company,
|
||||
line_name: lineLabel,
|
||||
subline_name: sublineLabel,
|
||||
});
|
||||
setValidatingField((prev) => (prev === "name" ? null : prev));
|
||||
if (result && result.isValid && !result.suggestion) {
|
||||
toast.success("Name looks good!");
|
||||
}
|
||||
}, [getValues, fieldOptions, lineOptions, sublineOptions, validateName, clearNameResult]);
|
||||
|
||||
const handleValidateDescription = useCallback(async () => {
|
||||
const values = getValues();
|
||||
if (!values.description?.trim() || values.description.trim().length < 10) return;
|
||||
clearDescriptionResult();
|
||||
setValidatingField("description");
|
||||
const companyLabel = fieldOptions.companies.find((c) => c.value === values.company)?.label;
|
||||
const categoryLabels = values.categories
|
||||
?.map((id) => fieldOptions.categories.find((c) => c.value === id)?.label)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const result = await validateDescription({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
company_name: companyLabel,
|
||||
company_id: values.company,
|
||||
categories: categoryLabels || undefined,
|
||||
});
|
||||
setValidatingField((prev) => (prev === "description" ? null : prev));
|
||||
if (result && result.isValid && !result.suggestion) {
|
||||
toast.success("Description looks good!");
|
||||
}
|
||||
}, [getValues, fieldOptions, validateDescription, clearDescriptionResult]);
|
||||
|
||||
// --- Embedding-based taxonomy suggestions ---
|
||||
const {
|
||||
categories: categorySuggestions,
|
||||
themes: themeSuggestions,
|
||||
colors: colorSuggestions,
|
||||
isLoading: isSuggestionsLoading,
|
||||
triggerFetch: triggerSuggestions,
|
||||
} = useProductSuggestions({
|
||||
name: product.title,
|
||||
description: product.description,
|
||||
company_name: product.brand,
|
||||
line_name: product.line,
|
||||
});
|
||||
|
||||
const hasImageChanges = computeImageChanges() !== null;
|
||||
const changedCount = Object.keys(dirtyFields).length;
|
||||
|
||||
@@ -421,8 +528,13 @@ export function ProductEditForm({
|
||||
style={{ gridTemplateColumns: `repeat(${group.cols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{group.fields.map((fc) => {
|
||||
const wrapSpan = (node: React.ReactNode) =>
|
||||
fc.colSpan && fc.colSpan > 1
|
||||
? <div key={fc.key} style={{ gridColumn: `span ${fc.colSpan}` }}>{node}</div>
|
||||
: node;
|
||||
|
||||
if (fc.type === "input") {
|
||||
return (
|
||||
return wrapSpan(
|
||||
<Controller
|
||||
key={fc.key}
|
||||
name={fc.key}
|
||||
@@ -443,7 +555,7 @@ export function ProductEditForm({
|
||||
);
|
||||
}
|
||||
if (fc.type === "combobox") {
|
||||
return (
|
||||
return wrapSpan(
|
||||
<Controller
|
||||
key={fc.key}
|
||||
name={fc.key}
|
||||
@@ -463,7 +575,12 @@ export function ProductEditForm({
|
||||
);
|
||||
}
|
||||
if (fc.type === "multiselect") {
|
||||
return (
|
||||
const fieldSuggestions =
|
||||
fc.key === "categories" ? categorySuggestions :
|
||||
fc.key === "themes" ? themeSuggestions :
|
||||
fc.key === "colors" ? colorSuggestions :
|
||||
undefined;
|
||||
return wrapSpan(
|
||||
<Controller
|
||||
key={fc.key}
|
||||
name={fc.key}
|
||||
@@ -477,15 +594,51 @@ export function ProductEditForm({
|
||||
placeholder="—"
|
||||
searchPlaceholder={fc.searchPlaceholder}
|
||||
showColors={fc.showColors}
|
||||
suggestions={fieldSuggestions}
|
||||
isLoadingSuggestions={isSuggestionsLoading}
|
||||
onOpen={triggerSuggestions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (fc.type === "textarea") {
|
||||
const isDescription = fc.key === "description";
|
||||
return (
|
||||
<div key={fc.key} className="col-span-full flex flex-col gap-0.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm hover:border-input hover:bg-muted/50 transition-colors">
|
||||
<span className="text-muted-foreground text-xs shrink-0">{fc.label}</span>
|
||||
<div className="flex items-center justify-between relative">
|
||||
<span className="text-muted-foreground text-xs shrink-0">{fc.label}</span>
|
||||
{isDescription && (watchDescription?.trim().length ?? 0) >= 10 && (
|
||||
descriptionResult?.suggestion ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescDialogOpen(true)}
|
||||
className="flex items-center gap-1 px-1.5 -mr-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors shrink-0"
|
||||
title="View AI suggestion"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span>{descriptionResult.issues.length}</span>
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 absolute top-0.5 -right-1 text-purple-500 hover:text-purple-600 transition-colors p-0.5"
|
||||
onClick={handleValidateDescription}
|
||||
disabled={validatingField === "description"}
|
||||
>
|
||||
{validatingField === "description"
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Sparkles className="h-4 w-4" />
|
||||
}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">AI validate description</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Textarea {...register(fc.key)} rows={(fc.key === "description" && MODE_LAYOUTS[layoutMode].descriptionRows) || fc.rows || 3} className="border-0 p-0 h-auto shadow-none focus-visible:ring-0 resize-y text-sm min-h-0" />
|
||||
</div>
|
||||
);
|
||||
@@ -499,7 +652,7 @@ export function ProductEditForm({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1">
|
||||
<div className="flex-1 min-w-0 flex items-start gap-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Controller
|
||||
name="name"
|
||||
@@ -512,9 +665,42 @@ export function ProductEditForm({
|
||||
placeholder="Product name"
|
||||
className="text-base font-semibold"
|
||||
inputClassName="text-base font-semibold"
|
||||
rightAction={
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-purple-500 hover:text-purple-600 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); handleValidateName(); }}
|
||||
disabled={validatingField === "name"}
|
||||
>
|
||||
{validatingField === "name"
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Sparkles className="h-4 w-4" />
|
||||
}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">AI validate name</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{nameResult?.suggestion && (
|
||||
<AiSuggestionBadge
|
||||
suggestion={nameResult.suggestion}
|
||||
issues={nameResult.issues}
|
||||
onAccept={(editedValue) => {
|
||||
setValue("name", editedValue, { shouldDirty: true });
|
||||
clearNameResult();
|
||||
}}
|
||||
onDismiss={clearNameResult}
|
||||
onRevalidate={handleValidateName}
|
||||
isRevalidating={validatingField === "name"}
|
||||
compact
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -611,6 +797,38 @@ export function ProductEditForm({
|
||||
renderFieldGroup(group, gi + MODE_LAYOUTS[layoutMode].sidebarGroups)
|
||||
)}
|
||||
|
||||
{/* AI Description Review Dialog */}
|
||||
{descriptionResult?.suggestion && (
|
||||
<Dialog open={descDialogOpen} onOpenChange={setDescDialogOpen}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||
AI Description Review
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AiDescriptionCompare
|
||||
currentValue={getValues("description")}
|
||||
onCurrentChange={(v) => setValue("description", v, { shouldDirty: true })}
|
||||
suggestion={descriptionResult.suggestion}
|
||||
issues={descriptionResult.issues}
|
||||
productName={getValues("name")}
|
||||
onAccept={(text) => {
|
||||
setValue("description", text, { shouldDirty: true });
|
||||
clearDescriptionResult();
|
||||
setDescDialogOpen(false);
|
||||
}}
|
||||
onDismiss={() => {
|
||||
clearDescriptionResult();
|
||||
setDescDialogOpen(false);
|
||||
}}
|
||||
onRevalidate={handleValidateDescription}
|
||||
isRevalidating={validatingField === "description"}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
|
||||
@@ -3,7 +3,6 @@ import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -12,10 +11,16 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Check, ChevronDown, Loader2, Search } from "lucide-react";
|
||||
import type { SearchProduct } from "./types";
|
||||
|
||||
const SEARCH_LIMIT = 100;
|
||||
|
||||
interface QuickSearchResult {
|
||||
pid: number;
|
||||
title: string;
|
||||
@@ -29,31 +34,44 @@ interface QuickSearchResult {
|
||||
|
||||
export function ProductSearch({
|
||||
onSelect,
|
||||
onLoadAll,
|
||||
onNewSearch,
|
||||
loadedPids,
|
||||
}: {
|
||||
onSelect: (product: SearchProduct) => void;
|
||||
onLoadAll: (pids: number[]) => void;
|
||||
onNewSearch: () => void;
|
||||
loadedPids: Set<number>;
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
|
||||
const [resultsOpen, setResultsOpen] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchTerm.trim()) return;
|
||||
setIsSearching(true);
|
||||
onNewSearch();
|
||||
try {
|
||||
const res = await axios.get("/api/products/search", {
|
||||
params: { q: searchTerm },
|
||||
});
|
||||
setSearchResults(res.data);
|
||||
setSearchResults(res.data.results);
|
||||
setTotalCount(res.data.total);
|
||||
setResultsOpen(true);
|
||||
} catch {
|
||||
toast.error("Search failed");
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [searchTerm]);
|
||||
}, [searchTerm, onNewSearch]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (product: QuickSearchResult) => {
|
||||
if (loadedPids.has(Number(product.pid))) return;
|
||||
setIsLoadingProduct(product.pid);
|
||||
try {
|
||||
const res = await axios.get("/api/import/search-products", {
|
||||
@@ -62,7 +80,7 @@ export function ProductSearch({
|
||||
const full = (res.data as SearchProduct[])[0];
|
||||
if (full) {
|
||||
onSelect(full);
|
||||
setSearchResults([]);
|
||||
setResultsOpen(false);
|
||||
} else {
|
||||
toast.error("Could not load full product details");
|
||||
}
|
||||
@@ -72,73 +90,124 @@ export function ProductSearch({
|
||||
setIsLoadingProduct(null);
|
||||
}
|
||||
},
|
||||
[onSelect]
|
||||
[onSelect, loadedPids]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Search Products</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Search by name, SKU, UPC, brand..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={isSearching}>
|
||||
{isSearching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
const handleLoadAll = useCallback(() => {
|
||||
const pids = searchResults
|
||||
.map((r) => r.pid)
|
||||
.filter((pid) => !loadedPids.has(Number(pid)));
|
||||
if (pids.length === 0) return;
|
||||
onLoadAll(pids);
|
||||
setResultsOpen(false);
|
||||
}, [searchResults, loadedPids, onLoadAll]);
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-4 border rounded-md">
|
||||
<ScrollArea className="max-h-80">
|
||||
const unloadedCount = searchResults.filter(
|
||||
(r) => !loadedPids.has(Number(r.pid))
|
||||
).length;
|
||||
const isTruncated = totalCount > SEARCH_LIMIT;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
placeholder="Search products…"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={isSearching}>
|
||||
{isSearching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{(isFocused || searchResults.length === 0) && (
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-3">
|
||||
Search by name, item number, UPC, company, supplier, supplier id, notions #, line, subline, artist
|
||||
</p>
|
||||
)}
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<Collapsible open={resultsOpen} onOpenChange={setResultsOpen} className="mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-2 text-muted-foreground">
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${resultsOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
{isTruncated
|
||||
? `Showing ${SEARCH_LIMIT} of ${totalCount} results`
|
||||
: `${totalCount} ${totalCount === 1 ? "result" : "results"}`}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{unloadedCount > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleLoadAll}>
|
||||
Load all results
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="border rounded-md mt-2 max-h-80 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead>Brand</TableHead>
|
||||
<TableHead>Line</TableHead>
|
||||
<TableHead className="text-right">Price</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Name</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Item Number</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Brand</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Line</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background text-right">
|
||||
Price
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{searchResults.map((product) => (
|
||||
<TableRow
|
||||
key={product.pid}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
|
||||
onClick={() => !isLoadingProduct && handleSelect(product)}
|
||||
>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
{isLoadingProduct === product.pid && (
|
||||
<Loader2 className="h-3 w-3 animate-spin inline mr-2" />
|
||||
)}
|
||||
{product.title}
|
||||
</TableCell>
|
||||
<TableCell>{product.sku}</TableCell>
|
||||
<TableCell>{product.brand}</TableCell>
|
||||
<TableCell>{product.line}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
$
|
||||
{Number(product.regular_price)?.toFixed(2) ??
|
||||
product.regular_price}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{searchResults.map((product) => {
|
||||
const isLoaded = loadedPids.has(Number(product.pid));
|
||||
return (
|
||||
<TableRow
|
||||
key={product.pid}
|
||||
className={`${isLoaded ? "opacity-50" : "cursor-pointer hover:bg-muted/50"} ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
|
||||
onClick={() =>
|
||||
!isLoadingProduct && !isLoaded && handleSelect(product)
|
||||
}
|
||||
>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
{isLoadingProduct === product.pid && (
|
||||
<Loader2 className="h-3 w-3 animate-spin inline mr-2" />
|
||||
)}
|
||||
{isLoaded && (
|
||||
<Check className="h-3 w-3 inline mr-2 text-green-600" />
|
||||
)}
|
||||
{product.title}
|
||||
</TableCell>
|
||||
<TableCell>{product.sku}</TableCell>
|
||||
<TableCell>{product.brand}</TableCell>
|
||||
<TableCell>{product.line}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
$
|
||||
{Number(product.regular_price)?.toFixed(2) ??
|
||||
product.regular_price}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{isTruncated && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Showing only the first {SEARCH_LIMIT} of {totalCount} matches. Refine your search to find specific products.
|
||||
</p>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface ProductFormValues {
|
||||
supplier_no: string;
|
||||
notions_no: string;
|
||||
msrp: string;
|
||||
current_price: string;
|
||||
cost_each: string;
|
||||
qty_per_unit: string;
|
||||
case_qty: string;
|
||||
|
||||
107
inventory/src/components/product-editor/useProductSuggestions.ts
Normal file
107
inventory/src/components/product-editor/useProductSuggestions.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* useProductSuggestions Hook
|
||||
*
|
||||
* Lazily fetches embedding-based taxonomy suggestions (categories, themes, colors)
|
||||
* for a product in the product editor.
|
||||
*
|
||||
* Mirrors the logic in AiSuggestionsContext but simplified for single-product use:
|
||||
* - Fetches once on first triggerFetch() call (no eager batch loading)
|
||||
* - Caches results in local state for the lifetime of the component
|
||||
* - Module-level init promise shared across all instances
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import type { TaxonomySuggestion, ProductSuggestions } from '@/components/product-import/steps/ValidationStep/store/types';
|
||||
|
||||
const API_BASE = '/api/ai';
|
||||
|
||||
// Module-level init promise — shared so we only call /initialize once
|
||||
let initPromise: Promise<boolean> | null = null;
|
||||
|
||||
async function ensureInitialized(): Promise<boolean> {
|
||||
if (!initPromise) {
|
||||
initPromise = fetch(`${API_BASE}/initialize`, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((d) => Boolean(d.success))
|
||||
.catch(() => {
|
||||
initPromise = null; // allow retry on next call
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
interface ProductInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
company_name?: string;
|
||||
line_name?: string;
|
||||
}
|
||||
|
||||
export interface ProductSuggestionResults {
|
||||
categories: TaxonomySuggestion[];
|
||||
themes: TaxonomySuggestion[];
|
||||
colors: TaxonomySuggestion[];
|
||||
isLoading: boolean;
|
||||
/** Call when a taxonomy dropdown opens to trigger a lazy fetch */
|
||||
triggerFetch: () => void;
|
||||
}
|
||||
|
||||
export function useProductSuggestions(product: ProductInput): ProductSuggestionResults {
|
||||
const [categories, setCategories] = useState<TaxonomySuggestion[]>([]);
|
||||
const [themes, setThemes] = useState<TaxonomySuggestion[]>([]);
|
||||
const [colors, setColors] = useState<TaxonomySuggestion[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Store current product in a ref so triggerFetch can read it without being re-created
|
||||
const productRef = useRef(product);
|
||||
productRef.current = product;
|
||||
|
||||
// Pre-warm: start initialization as soon as the form mounts so it's ready before
|
||||
// the first dropdown opens. With the disk cache this completes in < 1 second.
|
||||
useEffect(() => {
|
||||
ensureInitialized();
|
||||
}, []);
|
||||
|
||||
// Prevent duplicate fetches
|
||||
const hasFetchedRef = useRef(false);
|
||||
|
||||
const triggerFetch = useCallback(async () => {
|
||||
if (hasFetchedRef.current) return;
|
||||
const p = productRef.current;
|
||||
if (!p.name && !p.company_name) return;
|
||||
|
||||
hasFetchedRef.current = true;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const ready = await ensureInitialized();
|
||||
if (!ready) {
|
||||
hasFetchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/suggestions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ product: p }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
hasFetchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const data: ProductSuggestions = await response.json();
|
||||
setCategories(data.categories ?? []);
|
||||
setThemes(data.themes ?? []);
|
||||
setColors(data.colors ?? []);
|
||||
} catch {
|
||||
hasFetchedRef.current = false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { categories, themes, colors, isLoading, triggerFetch };
|
||||
}
|
||||
@@ -193,18 +193,6 @@ export const BASE_IMPORT_FIELDS = [
|
||||
fieldType: { type: "input" },
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
label: "Weight",
|
||||
key: "weight",
|
||||
description: "Product weight (in lbs)",
|
||||
alternateMatches: ["weight (lbs.)"],
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Length",
|
||||
key: "length",
|
||||
@@ -238,6 +226,18 @@ export const BASE_IMPORT_FIELDS = [
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Weight",
|
||||
key: "weight",
|
||||
description: "Product weight (in lbs)",
|
||||
alternateMatches: ["weight (lbs.)"],
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Shipping Restrictions",
|
||||
key: "ship_restrictions",
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
* Used for inline validation suggestions on Name and Description fields.
|
||||
*
|
||||
* For description fields, starts collapsed (just icon + count) and expands on click.
|
||||
* For name fields, uses compact inline mode.
|
||||
* For name fields, uses compact inline mode with an editable suggestion.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -24,10 +24,14 @@ interface AiSuggestionBadgeProps {
|
||||
suggestion: string;
|
||||
/** List of issues found (optional) */
|
||||
issues?: string[];
|
||||
/** Called when user accepts the suggestion */
|
||||
onAccept: () => void;
|
||||
/** Called when user accepts the suggestion (receives the possibly-edited value) */
|
||||
onAccept: (editedValue: string) => void;
|
||||
/** Called when user dismisses the suggestion */
|
||||
onDismiss: () => void;
|
||||
/** Called to refresh (re-run) the AI validation */
|
||||
onRevalidate?: () => void;
|
||||
/** Whether re-validation is in progress */
|
||||
isRevalidating?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Whether to show the suggestion as compact (inline) - used for name field */
|
||||
@@ -41,13 +45,21 @@ export function AiSuggestionBadge({
|
||||
issues = [],
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onRevalidate,
|
||||
isRevalidating = false,
|
||||
className,
|
||||
compact = false,
|
||||
collapsible = false
|
||||
}: AiSuggestionBadgeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [editedValue, setEditedValue] = useState(suggestion);
|
||||
|
||||
// Compact mode for name fields - inline suggestion with accept/dismiss
|
||||
// Reset edited value when suggestion changes (e.g. after refresh)
|
||||
useEffect(() => {
|
||||
setEditedValue(suggestion);
|
||||
}, [suggestion]);
|
||||
|
||||
// Compact mode for name fields - inline editable suggestion with accept/dismiss
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
@@ -58,24 +70,27 @@ export function AiSuggestionBadge({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<div className="flex items-start gap-1.5 flex-1 min-w-0">
|
||||
<Sparkles className="h-3 w-3 text-purple-500 flex-shrink-0 mt-0.5" />
|
||||
|
||||
<span className="text-purple-700 dark:text-purple-300">
|
||||
{suggestion}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={editedValue}
|
||||
onChange={(e) => setEditedValue(e.target.value)}
|
||||
className="flex-1 min-w-0 bg-transparent text-purple-700 dark:text-purple-300 text-xs outline-none border-b border-transparent focus:border-purple-300 dark:focus:border-purple-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<div className="flex items-center gap-[0px] flex-shrink-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-100"
|
||||
className="h-4 w-4 p-0 [&_svg]:size-3.5 text-green-600 hover:text-green-700 hover:bg-green-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAccept();
|
||||
onAccept(editedValue);
|
||||
}}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
@@ -92,7 +107,7 @@ export function AiSuggestionBadge({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||
className="h-4 w-4 p-0 [&_svg]:size-3.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
@@ -106,18 +121,45 @@ export function AiSuggestionBadge({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/* Refresh button */}
|
||||
{onRevalidate && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 mr-[1px] [&_svg]:size-3 text-purple-400 hover:text-purple-600 hover:bg-purple-100"
|
||||
disabled={isRevalidating}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRevalidate();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className={cn(isRevalidating && "animate-spin")} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Refresh suggestion</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{/* Info icon with issues tooltip */}
|
||||
{issues.length > 0 && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 text-purple-400 hover:text-purple-600 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="h-4 w-4 p-0 [&_svg]:size-3.5 text-purple-400 hover:text-purple-600 hover:bg-purple-100"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
@@ -246,7 +288,7 @@ export function AiSuggestionBadge({
|
||||
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAccept();
|
||||
onAccept(suggestion);
|
||||
}}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
|
||||
@@ -588,9 +588,9 @@ const CellWrapper = memo(({
|
||||
// Check if description should be validated
|
||||
const descIsDismissed = nameSuggestion?.dismissed?.description;
|
||||
const descIsValidating = inlineAi.validating.has(`${contextProductIndex}-description`);
|
||||
const descValue = currentRowForContext.description && String(currentRowForContext.description).trim();
|
||||
const descValue = currentRowForContext.description ? String(currentRowForContext.description).trim() : '';
|
||||
|
||||
if (descValue && !descIsDismissed && !descIsValidating) {
|
||||
if (descValue.length >= 10 && !descIsDismissed && !descIsValidating) {
|
||||
// Trigger description validation
|
||||
setInlineAiValidating(`${contextProductIndex}-description`, true);
|
||||
|
||||
@@ -687,7 +687,9 @@ const CellWrapper = memo(({
|
||||
// Trigger inline AI validation for name/description fields
|
||||
// This validates spelling, grammar, and naming conventions using Groq
|
||||
// Only trigger if value actually changed to avoid unnecessary API calls
|
||||
if (isInlineAiField && valueChanged && valueToSave && String(valueToSave).trim()) {
|
||||
const trimmedValue = valueToSave ? String(valueToSave).trim() : '';
|
||||
const meetsMinLength = field.key === 'description' ? trimmedValue.length >= 10 : trimmedValue.length > 0;
|
||||
if (isInlineAiField && valueChanged && meetsMinLength) {
|
||||
const currentRow = useValidationStore.getState().rows[rowIndex];
|
||||
const fields = useValidationStore.getState().fields;
|
||||
if (currentRow) {
|
||||
@@ -751,6 +753,66 @@ const CellWrapper = memo(({
|
||||
}, 0);
|
||||
}, [rowIndex, field.key, isEmbeddingField, aiSuggestions, isInlineAiField, productIndex]);
|
||||
|
||||
// Manual re-validate: triggers inline AI validation regardless of value changes
|
||||
const handleRevalidate = useCallback(() => {
|
||||
if (!isInlineAiField) return;
|
||||
const state = useValidationStore.getState();
|
||||
const currentRow = state.rows[rowIndex];
|
||||
if (!currentRow) return;
|
||||
|
||||
const fieldKey = field.key as 'name' | 'description';
|
||||
const currentValue = String(currentRow[fieldKey] ?? '').trim();
|
||||
|
||||
// Name requires non-empty, description requires ≥10 chars
|
||||
if (fieldKey === 'name' && !currentValue) return;
|
||||
if (fieldKey === 'description' && currentValue.length < 10) return;
|
||||
|
||||
const validationKey = `${productIndex}-${fieldKey}`;
|
||||
if (state.inlineAi.validating.has(validationKey)) return;
|
||||
|
||||
const { setInlineAiValidating, setInlineAiSuggestion, markInlineAiAutoValidated, fields: storeFields, rows } = state;
|
||||
setInlineAiValidating(validationKey, true);
|
||||
markInlineAiAutoValidated(productIndex, fieldKey);
|
||||
|
||||
// Clear dismissed state so new result shows
|
||||
const suggestions = state.inlineAi.suggestions.get(productIndex);
|
||||
if (suggestions?.dismissed?.[fieldKey]) {
|
||||
// Reset dismissed by re-setting suggestion (will be overwritten by API result)
|
||||
setInlineAiSuggestion(productIndex, fieldKey, {
|
||||
isValid: true,
|
||||
suggestion: undefined,
|
||||
issues: [],
|
||||
});
|
||||
}
|
||||
|
||||
const payload = fieldKey === 'name'
|
||||
? buildNameValidationPayload(currentRow, storeFields, rows)
|
||||
: buildDescriptionValidationPayload(currentRow, storeFields);
|
||||
|
||||
const endpoint = fieldKey === 'name'
|
||||
? '/api/ai/validate/inline/name'
|
||||
: '/api/ai/validate/inline/description';
|
||||
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ product: payload }),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.success !== false) {
|
||||
setInlineAiSuggestion(productIndex, fieldKey, {
|
||||
isValid: result.isValid ?? true,
|
||||
suggestion: result.suggestion,
|
||||
issues: result.issues || [],
|
||||
latencyMs: result.latencyMs,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => console.error(`[InlineAI] manual ${fieldKey} revalidation error:`, err))
|
||||
.finally(() => setInlineAiValidating(validationKey, false));
|
||||
}, [rowIndex, field.key, isInlineAiField, productIndex]);
|
||||
|
||||
// Stable callback for fetching options (for line/subline dropdowns)
|
||||
const handleFetchOptions = useCallback(async () => {
|
||||
const state = useValidationStore.getState();
|
||||
@@ -854,6 +916,7 @@ const CellWrapper = memo(({
|
||||
onDismissAiSuggestion: () => {
|
||||
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
|
||||
},
|
||||
onRevalidate: handleRevalidate,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@@ -925,12 +988,18 @@ const CellWrapper = memo(({
|
||||
<AiSuggestionBadge
|
||||
suggestion={fieldSuggestion.suggestion!}
|
||||
issues={fieldSuggestion.issues}
|
||||
onAccept={() => {
|
||||
useValidationStore.getState().acceptInlineAiSuggestion(productIndex, 'name');
|
||||
onAccept={(editedValue) => {
|
||||
const state = useValidationStore.getState();
|
||||
// Update the cell with the (possibly edited) value
|
||||
state.updateCell(rowIndex, 'name', editedValue);
|
||||
// Dismiss the suggestion
|
||||
state.dismissInlineAiSuggestion(productIndex, 'name');
|
||||
}}
|
||||
onDismiss={() => {
|
||||
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'name');
|
||||
}}
|
||||
onRevalidate={handleRevalidate}
|
||||
isRevalidating={isInlineAiValidating}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { X, Loader2, Sparkles, AlertCircle, Check } from 'lucide-react';
|
||||
import { X, Loader2, Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AiDescriptionCompare } from '@/components/ai/AiDescriptionCompare';
|
||||
import type { Field, SelectOption } from '../../../../types';
|
||||
import type { ValidationError } from '../../store/types';
|
||||
import { useValidationStore } from '../../store/validationStore';
|
||||
@@ -50,6 +51,8 @@ interface MultilineInputProps {
|
||||
isAiValidating?: boolean;
|
||||
/** Called when user dismisses/clears the AI suggestion (also called after applying) */
|
||||
onDismissAiSuggestion?: () => void;
|
||||
/** Called to manually trigger AI re-validation */
|
||||
onRevalidate?: () => void;
|
||||
}
|
||||
|
||||
const MultilineInputComponent = ({
|
||||
@@ -63,12 +66,11 @@ const MultilineInputComponent = ({
|
||||
aiSuggestion,
|
||||
isAiValidating,
|
||||
onDismissAiSuggestion,
|
||||
onRevalidate,
|
||||
}: MultilineInputProps) => {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
||||
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
|
||||
const [editedSuggestion, setEditedSuggestion] = useState('');
|
||||
const [popoverWidth, setPopoverWidth] = useState(400);
|
||||
const [popoverHeight, setPopoverHeight] = useState<number | undefined>(undefined);
|
||||
const resizeContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -77,12 +79,8 @@ const MultilineInputComponent = ({
|
||||
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
|
||||
const intentionalCloseRef = useRef(false);
|
||||
const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
// Tracks the value when popover opened, to detect actual changes
|
||||
const initialEditValueRef = useRef('');
|
||||
// Ref for the right-side header+issues area to measure its height for left-side spacer
|
||||
const aiHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const [aiHeaderHeight, setAiHeaderHeight] = useState(0);
|
||||
|
||||
// Get the product name for this row from the store
|
||||
const productName = useValidationStore(
|
||||
@@ -121,13 +119,6 @@ const MultilineInputComponent = ({
|
||||
}
|
||||
}, [value, localDisplayValue]);
|
||||
|
||||
// Initialize edited suggestion when AI suggestion changes
|
||||
useEffect(() => {
|
||||
if (aiSuggestion?.suggestion) {
|
||||
setEditedSuggestion(aiSuggestion.suggestion);
|
||||
}
|
||||
}, [aiSuggestion?.suggestion]);
|
||||
|
||||
// Auto-resize a textarea to fit its content
|
||||
const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement | null) => {
|
||||
if (!textarea) return;
|
||||
@@ -145,61 +136,25 @@ const MultilineInputComponent = ({
|
||||
}
|
||||
}, [popoverOpen, editValue, autoResizeTextarea]);
|
||||
|
||||
// Auto-resize suggestion textarea when expanded/visible or value changes
|
||||
// Set initial popover height to fit the textarea content, capped by window height.
|
||||
// Only applies on desktop (lg breakpoint) and non-AI mode (AI mode uses AiDescriptionCompare's own sizing).
|
||||
useEffect(() => {
|
||||
if (aiSuggestionExpanded || (popoverOpen && hasAiSuggestion)) {
|
||||
requestAnimationFrame(() => {
|
||||
autoResizeTextarea(suggestionTextareaRef.current);
|
||||
});
|
||||
}
|
||||
}, [aiSuggestionExpanded, popoverOpen, hasAiSuggestion, editedSuggestion, autoResizeTextarea]);
|
||||
|
||||
// Set initial popover height to fit the tallest textarea content, capped by window height.
|
||||
// Only applies on desktop (lg breakpoint) — mobile uses natural flow with individually resizable textareas.
|
||||
useEffect(() => {
|
||||
if (!popoverOpen) { setPopoverHeight(undefined); return; }
|
||||
if (!popoverOpen || hasAiSuggestion) { setPopoverHeight(undefined); return; }
|
||||
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
|
||||
if (!isDesktop) { setPopoverHeight(undefined); return; }
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const main = mainTextareaRef.current;
|
||||
const suggestion = suggestionTextareaRef.current;
|
||||
const container = resizeContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Get textarea natural content heights
|
||||
const mainScrollH = main ? main.scrollHeight : 0;
|
||||
const suggestionScrollH = suggestion ? suggestion.scrollHeight : 0;
|
||||
const tallestTextarea = Math.max(mainScrollH, suggestionScrollH);
|
||||
|
||||
// Measure chrome for both columns (everything except the textarea)
|
||||
const leftChrome = main ? (main.closest('[data-col="left"]')?.scrollHeight ?? 0) - main.offsetHeight : 0;
|
||||
const rightChrome = suggestion ? (suggestion.closest('[data-col="right"]')?.scrollHeight ?? 0) - suggestion.offsetHeight : 0;
|
||||
const chrome = Math.max(leftChrome, rightChrome);
|
||||
|
||||
const naturalHeight = chrome + tallestTextarea;
|
||||
const naturalHeight = leftChrome + mainScrollH;
|
||||
const maxHeight = Math.floor(window.innerHeight * 0.7);
|
||||
setPopoverHeight(Math.max(Math.min(naturalHeight, maxHeight), 200));
|
||||
});
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [popoverOpen]);
|
||||
|
||||
// Measure the right-side header+issues area so the left spacer matches.
|
||||
// Uses rAF because Radix portals mount asynchronously, so the ref is null on the first synchronous run.
|
||||
useEffect(() => {
|
||||
if (!popoverOpen || !hasAiSuggestion) { setAiHeaderHeight(0); return; }
|
||||
let observer: ResizeObserver | null = null;
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const el = aiHeaderRef.current;
|
||||
if (!el) return;
|
||||
observer = new ResizeObserver(([entry]) => {
|
||||
setAiHeaderHeight(entry.contentRect.height-7);
|
||||
});
|
||||
observer.observe(el);
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [popoverOpen, hasAiSuggestion]);
|
||||
|
||||
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
|
||||
@@ -261,7 +216,6 @@ const MultilineInputComponent = ({
|
||||
|
||||
// Immediately close popover
|
||||
setPopoverOpen(false);
|
||||
setAiSuggestionExpanded(false);
|
||||
|
||||
// Prevent reopening this same cell
|
||||
preventReopenRef.current = true;
|
||||
@@ -291,7 +245,6 @@ const MultilineInputComponent = ({
|
||||
}
|
||||
|
||||
setPopoverOpen(false);
|
||||
setAiSuggestionExpanded(false);
|
||||
|
||||
// Signal to other cells that a popover just closed via click-outside
|
||||
setCellPopoverClosed();
|
||||
@@ -322,23 +275,19 @@ const MultilineInputComponent = ({
|
||||
autoResizeTextarea(e.target);
|
||||
}, [autoResizeTextarea]);
|
||||
|
||||
// Handle accepting the AI suggestion (possibly edited)
|
||||
const handleAcceptSuggestion = useCallback(() => {
|
||||
// Use the edited suggestion
|
||||
setEditValue(editedSuggestion);
|
||||
setLocalDisplayValue(editedSuggestion);
|
||||
// onBlur handles both cell update and validation
|
||||
onBlur(editedSuggestion);
|
||||
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
|
||||
setAiSuggestionExpanded(false);
|
||||
// Handle accepting the AI suggestion (possibly edited) via AiDescriptionCompare
|
||||
const handleAcceptSuggestion = useCallback((text: string) => {
|
||||
setEditValue(text);
|
||||
setLocalDisplayValue(text);
|
||||
onBlur(text);
|
||||
onDismissAiSuggestion?.();
|
||||
intentionalCloseRef.current = true;
|
||||
setPopoverOpen(false);
|
||||
}, [editedSuggestion, onBlur, onDismissAiSuggestion]);
|
||||
}, [onBlur, onDismissAiSuggestion]);
|
||||
|
||||
// Handle dismissing the AI suggestion
|
||||
// Handle dismissing the AI suggestion via AiDescriptionCompare
|
||||
const handleDismissSuggestion = useCallback(() => {
|
||||
onDismissAiSuggestion?.();
|
||||
setAiSuggestionExpanded(false);
|
||||
intentionalCloseRef.current = true;
|
||||
setPopoverOpen(false);
|
||||
}, [onDismissAiSuggestion]);
|
||||
@@ -380,7 +329,6 @@ const MultilineInputComponent = ({
|
||||
return;
|
||||
}
|
||||
updatePopoverWidth();
|
||||
setAiSuggestionExpanded(true);
|
||||
setPopoverOpen(true);
|
||||
// Initialize edit value and track it for change detection
|
||||
const initValue = localDisplayValue || String(value ?? '');
|
||||
@@ -436,7 +384,12 @@ const MultilineInputComponent = ({
|
||||
>
|
||||
<div
|
||||
ref={resizeContainerRef}
|
||||
className="flex flex-col lg:flex-row items-stretch lg:resize-y lg:overflow-auto lg:min-h-[120px] max-h-[85vh] overflow-y-auto lg:max-h-none"
|
||||
className={cn(
|
||||
"flex flex-col lg:flex-row items-stretch max-h-[85vh]",
|
||||
hasAiSuggestion
|
||||
? "overflow-y-auto lg:overflow-hidden"
|
||||
: "lg:resize-y lg:overflow-auto lg:min-h-[120px] overflow-y-auto lg:max-h-none"
|
||||
)}
|
||||
style={popoverHeight ? { height: popoverHeight } : undefined}
|
||||
>
|
||||
{/* Close button */}
|
||||
@@ -449,116 +402,29 @@ const MultilineInputComponent = ({
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{/* Main textarea */}
|
||||
<div data-col="left" className={cn("flex flex-col min-h-0 w-full", hasAiSuggestion && "lg:w-1/2")}>
|
||||
<div className={cn(hasAiSuggestion ? 'px-3 py-2 bg-accent' : '', 'flex flex-col flex-1 min-h-0')}>
|
||||
{/* Product name - shown inline on mobile, in measured spacer on desktop */}
|
||||
{hasAiSuggestion && productName && (
|
||||
<div className="flex-shrink-0 flex flex-col lg:hidden px-1 mb-2">
|
||||
<div className="text-sm font-medium text-foreground mb-1">Editing description for:</div>
|
||||
<div className="text-md font-semibold text-foreground line-clamp-1">{productName}</div>
|
||||
</div>
|
||||
)}
|
||||
{hasAiSuggestion && aiHeaderHeight > 0 && (
|
||||
<div className="flex-shrink-0 hidden lg:flex items-start" style={{ height: aiHeaderHeight }}>
|
||||
{productName && (
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium text-foreground px-1 mb-1">Editing description for:</div>
|
||||
<div className="text-md font-semibold text-foreground line-clamp-1 px-1">{productName}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasAiSuggestion && <div className="text-sm mb-1 font-medium flex items-center gap-2 flex-shrink-0">
|
||||
Current Description:
|
||||
</div>}
|
||||
{/* Dynamic spacer matching the right-side header+issues height */}
|
||||
|
||||
<Textarea
|
||||
ref={mainTextareaRef}
|
||||
value={editValue}
|
||||
onChange={handleChange}
|
||||
onWheel={handleTextareaWheel}
|
||||
className={cn("overflow-y-auto overscroll-contain text-sm lg:flex-1 resize-y lg:resize-none bg-white min-h-[120px] lg:min-h-0")}
|
||||
placeholder={`Enter ${field.label || 'text'}...`}
|
||||
autoFocus
|
||||
/>
|
||||
{hasAiSuggestion && <div className="h-[43px] flex-shrink-0 hidden lg:block" />}
|
||||
</div></div>
|
||||
{/* AI Suggestion section */}
|
||||
{hasAiSuggestion && (
|
||||
<div data-col="right" className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2">
|
||||
{/* Measured header + issues area (mirrored as spacer on the left) */}
|
||||
<div ref={aiHeaderRef} className="flex-shrink-0">
|
||||
{/* Header */}
|
||||
<div className="w-full flex items-center justify-between px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||
AI Suggestion
|
||||
</span>
|
||||
<span className="text-xs text-purple-500 dark:text-purple-400">
|
||||
({aiIssues.length} {aiIssues.length === 1 ? 'issue' : 'issues'})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issues list */}
|
||||
{aiIssues.length > 0 && (
|
||||
<div className="flex flex-col gap-1 px-3 pb-3">
|
||||
{aiIssues.map((issue, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||
<span>{issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
|
||||
{/* Editable suggestion */}
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="text-sm text-purple-500 dark:text-purple-400 mb-1 font-medium flex-shrink-0">
|
||||
Suggested (editable):
|
||||
</div>
|
||||
<Textarea
|
||||
ref={suggestionTextareaRef}
|
||||
value={editedSuggestion}
|
||||
onChange={(e) => {
|
||||
setEditedSuggestion(e.target.value);
|
||||
autoResizeTextarea(e.target);
|
||||
}}
|
||||
onWheel={handleTextareaWheel}
|
||||
className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y lg:resize-none lg:flex-1 min-h-[120px] lg:min-h-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||
onClick={handleAcceptSuggestion}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Replace With Suggestion
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||
onClick={handleDismissSuggestion}
|
||||
>
|
||||
Ignore
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{hasAiSuggestion ? (
|
||||
<AiDescriptionCompare
|
||||
currentValue={editValue}
|
||||
onCurrentChange={setEditValue}
|
||||
suggestion={aiSuggestion.suggestion!}
|
||||
issues={aiIssues}
|
||||
productName={productName}
|
||||
onAccept={handleAcceptSuggestion}
|
||||
onDismiss={handleDismissSuggestion}
|
||||
onRevalidate={onRevalidate}
|
||||
isRevalidating={isAiValidating}
|
||||
/>
|
||||
) : (
|
||||
<div data-col="left" className="flex flex-col min-h-0 w-full">
|
||||
<Textarea
|
||||
ref={mainTextareaRef}
|
||||
value={editValue}
|
||||
onChange={handleChange}
|
||||
onWheel={handleTextareaWheel}
|
||||
className="overflow-y-auto overscroll-contain text-sm lg:flex-1 resize-y lg:resize-none bg-white min-h-[120px] lg:min-h-0"
|
||||
placeholder={`Enter ${field.label || 'text'}...`}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -108,9 +108,9 @@ export function useAutoInlineAiValidation() {
|
||||
typeof row.name === 'string' &&
|
||||
row.name.trim();
|
||||
|
||||
// Check description context: company + line + name (description can be empty)
|
||||
// We want to validate descriptions even when empty so AI can suggest one
|
||||
const hasDescContext = hasNameContext;
|
||||
// Check description context: company + line + name + description with ≥10 chars
|
||||
const descriptionValue = typeof row.description === 'string' ? row.description.trim() : '';
|
||||
const hasDescContext = hasNameContext && descriptionValue.length >= 10;
|
||||
|
||||
// Skip if already auto-validated (shouldn't happen on first run, but be safe)
|
||||
const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`);
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
InlineAiValidationResult,
|
||||
} from './types';
|
||||
import type { Field, SelectOption } from '../../../types';
|
||||
import { stripPriceFormatting } from '../utils/priceUtils';
|
||||
|
||||
// =============================================================================
|
||||
// Initial State
|
||||
@@ -165,11 +166,24 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
// Apply fresh state first (clean slate)
|
||||
Object.assign(state, freshState);
|
||||
|
||||
// Then set up with new data
|
||||
state.rows = data.map((row) => ({
|
||||
...row,
|
||||
__index: row.__index || uuidv4(),
|
||||
}));
|
||||
// Identify price fields to clean on ingestion (strips $, commas, whitespace)
|
||||
const priceFieldKeys = fields
|
||||
.filter((f) => f.fieldType.type === 'input' && 'price' in f.fieldType && f.fieldType.price)
|
||||
.map((f) => f.key);
|
||||
|
||||
// Then set up with new data, cleaning price fields
|
||||
state.rows = data.map((row) => {
|
||||
const cleanedRow: RowData = {
|
||||
...row,
|
||||
__index: row.__index || uuidv4(),
|
||||
};
|
||||
for (const key of priceFieldKeys) {
|
||||
if (typeof cleanedRow[key] === 'string' && cleanedRow[key] !== '') {
|
||||
cleanedRow[key] = stripPriceFormatting(cleanedRow[key] as string);
|
||||
}
|
||||
}
|
||||
return cleanedRow;
|
||||
});
|
||||
state.originalRows = JSON.parse(JSON.stringify(state.rows));
|
||||
// Cast to bypass immer's strict readonly type checking
|
||||
state.fields = fields as unknown as typeof state.fields;
|
||||
|
||||
@@ -2,10 +2,84 @@
|
||||
* Price field cleaning and formatting utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalizes a numeric string that may use US or European formatting conventions.
|
||||
*
|
||||
* Handles the ambiguity between comma-as-thousands (US: "1,234.56") and
|
||||
* comma-as-decimal (European: "1.234,56" or "1,50") using these heuristics:
|
||||
*
|
||||
* 1. Both comma AND period present → last one is the decimal separator
|
||||
* - "1,234.56" → period last → US → "1234.56"
|
||||
* - "1.234,56" → comma last → EU → "1234.56"
|
||||
*
|
||||
* 2. Only comma, no period → check digit count after last comma:
|
||||
* - 1-2 digits → decimal comma: "1,50" → "1.50"
|
||||
* - 3 digits → thousands: "1,500" → "1500"
|
||||
*
|
||||
* 3. Only period or neither → return as-is
|
||||
*/
|
||||
function normalizeNumericSeparators(value: string): string {
|
||||
if (value.includes(".") && value.includes(",")) {
|
||||
const lastComma = value.lastIndexOf(",");
|
||||
const lastPeriod = value.lastIndexOf(".");
|
||||
if (lastPeriod > lastComma) {
|
||||
// US: "1,234.56" → remove commas
|
||||
return value.replace(/,/g, "");
|
||||
} else {
|
||||
// European: "1.234,56" → remove periods, comma→period
|
||||
return value.replace(/\./g, "").replace(",", ".");
|
||||
}
|
||||
}
|
||||
|
||||
if (value.includes(",")) {
|
||||
const match = value.match(/,(\d+)$/);
|
||||
if (match && match[1].length <= 2) {
|
||||
// Decimal comma: "1,50" → "1.50", "1,5" → "1.5"
|
||||
return value.replace(",", ".");
|
||||
}
|
||||
// Thousands comma(s): "1,500" or "1,000,000" → remove all
|
||||
return value.replace(/,/g, "");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips currency formatting from a price string without rounding.
|
||||
*
|
||||
* Removes currency symbols and whitespace, normalizes European decimal commas,
|
||||
* and returns the raw numeric string. Full precision is preserved.
|
||||
*
|
||||
* @returns Stripped numeric string, or original value if not a valid number
|
||||
*
|
||||
* @example
|
||||
* stripPriceFormatting(" $ 1.50") // "1.50"
|
||||
* stripPriceFormatting("$1,234.56") // "1234.56"
|
||||
* stripPriceFormatting("1.234,56") // "1234.56"
|
||||
* stripPriceFormatting("1,50") // "1.50"
|
||||
* stripPriceFormatting("3.625") // "3.625"
|
||||
* stripPriceFormatting("invalid") // "invalid"
|
||||
*/
|
||||
export function stripPriceFormatting(value: string): string {
|
||||
// Step 1: Strip whitespace and currency symbols (keep commas/periods for separator detection)
|
||||
let cleaned = value.replace(/[\s$€£¥]/g, "");
|
||||
|
||||
// Step 2: Normalize decimal/thousands separators
|
||||
cleaned = normalizeNumericSeparators(cleaned);
|
||||
|
||||
// Verify it's actually a number after normalization
|
||||
const numValue = parseFloat(cleaned);
|
||||
if (!isNaN(numValue) && cleaned !== "") {
|
||||
return cleaned;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans a price field by removing currency symbols and formatting to 2 decimal places
|
||||
*
|
||||
* - Removes dollar signs ($) and commas (,)
|
||||
* - Removes currency symbols and whitespace
|
||||
* - Normalizes European decimal commas
|
||||
* - Converts to number and formats with 2 decimal places
|
||||
* - Returns original value if conversion fails
|
||||
*
|
||||
@@ -14,13 +88,14 @@
|
||||
*
|
||||
* @example
|
||||
* cleanPriceField("$1,234.56") // "1234.56"
|
||||
* cleanPriceField("$99.9") // "99.90"
|
||||
* cleanPriceField(123.456) // "123.46"
|
||||
* cleanPriceField("invalid") // "invalid"
|
||||
* cleanPriceField(" $ 99.9") // "99.90"
|
||||
* cleanPriceField("1,50") // "1.50"
|
||||
* cleanPriceField(123.456) // "123.46"
|
||||
* cleanPriceField("invalid") // "invalid"
|
||||
*/
|
||||
export function cleanPriceField(value: string | number): string {
|
||||
if (typeof value === "string") {
|
||||
const cleaned = value.replace(/[$,]/g, "");
|
||||
const cleaned = stripPriceFormatting(value);
|
||||
const numValue = parseFloat(cleaned);
|
||||
if (!isNaN(numValue)) {
|
||||
return numValue.toFixed(2);
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
export function cleanPriceField(value: string | number): string {
|
||||
if (typeof value === "string") {
|
||||
const cleaned = value.replace(/[$,]/g, "");
|
||||
const cleaned = value.replace(/[\s$,]/g, "");
|
||||
const numValue = parseFloat(cleaned);
|
||||
if (!isNaN(numValue)) {
|
||||
return numValue.toFixed(2);
|
||||
|
||||
@@ -19,8 +19,9 @@ import { StatusBadge } from "@/components/products/StatusBadge";
|
||||
import { transformMetricsRow } from "@/utils/transformUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import config from "@/config";
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from "recharts";
|
||||
import { ResponsiveContainer, LineChart, Line, AreaChart, Area, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { format } from "date-fns";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
|
||||
// Interfaces for POs and time series data
|
||||
@@ -46,6 +47,26 @@ interface ProductTimeSeries {
|
||||
recentPurchases: ProductPurchaseOrder[];
|
||||
}
|
||||
|
||||
interface ProductForecast {
|
||||
phase: string | null;
|
||||
method: string | null;
|
||||
forecast: {
|
||||
date: string;
|
||||
units: number;
|
||||
revenue: number;
|
||||
confidenceLower: number;
|
||||
confidenceUpper: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
preorder: "Pre-order",
|
||||
launch: "Launch",
|
||||
decay: "Active Decay",
|
||||
mature: "Evergreen",
|
||||
dormant: "Dormant",
|
||||
};
|
||||
|
||||
interface ProductDetailProps {
|
||||
productId: number | null;
|
||||
onClose: () => void;
|
||||
@@ -109,6 +130,18 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
enabled: !!productId, // Only run query when productId is truthy
|
||||
});
|
||||
|
||||
// Fetch product forecast data
|
||||
const { data: forecastData, isLoading: isLoadingForecast } = useQuery<ProductForecast, Error>({
|
||||
queryKey: ["productForecast", productId],
|
||||
queryFn: async () => {
|
||||
if (!productId) throw new Error("Product ID is required");
|
||||
const response = await fetch(`${config.apiUrl}/products/${productId}/forecast`, {credentials: 'include'});
|
||||
if (!response.ok) throw new Error("Failed to fetch forecast");
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!productId,
|
||||
});
|
||||
|
||||
// Get PO status display names (DB stores text statuses)
|
||||
const getPOStatusName = (status: string): string => {
|
||||
const statusMap: Record<string, string> = {
|
||||
@@ -220,7 +253,6 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<InfoItem label="Current Price" value={formatCurrency(product.currentPrice)} />
|
||||
<InfoItem label="Regular Price" value={formatCurrency(product.currentRegularPrice)} />
|
||||
<InfoItem label="Cost Price" value={formatCurrency(product.currentCostPrice)} />
|
||||
<InfoItem label="Landing Cost" value={formatCurrency(product.currentLandingCostPrice)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -329,6 +361,72 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Forecast Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">90-Day Forecast</CardTitle>
|
||||
<CardDescription>
|
||||
{forecastData?.phase
|
||||
? `${PHASE_LABELS[forecastData.phase] || forecastData.phase} phase \u00b7 ${forecastData.method || 'unknown'} method`
|
||||
: 'Lifecycle-aware demand forecast'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
{isLoadingForecast ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Skeleton className="h-[250px] w-full" />
|
||||
</div>
|
||||
) : forecastData && forecastData.forecast.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={forecastData.forecast}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(d) => format(new Date(d + 'T00:00:00'), 'MMM d')}
|
||||
interval="preserveStartEnd"
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 11 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
labelFormatter={(d) => format(new Date(d + 'T00:00:00'), 'MMM d, yyyy')}
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Revenue') return [formatCurrency(value), name];
|
||||
return [value.toFixed(1), name];
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="units"
|
||||
name="Units"
|
||||
stroke="#8884d8"
|
||||
fill="#8884d8"
|
||||
fillOpacity={0.15}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#82ca9d"
|
||||
fill="#82ca9d"
|
||||
fillOpacity={0.15}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<p>No forecast data available for this product.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Sales Performance (30 Days)</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
@@ -536,6 +634,8 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Forecasting</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Lifecycle Phase" value={forecastData?.phase ? (PHASE_LABELS[forecastData.phase] || forecastData.phase) : 'N/A'} />
|
||||
<InfoItem label="Forecast Method" value={forecastData?.method || 'N/A'} />
|
||||
<InfoItem label="Replenishment Units" value={formatNumber(product.replenishmentUnits)} />
|
||||
<InfoItem label="Replenishment Cost" value={formatCurrency(product.replenishmentCost)} />
|
||||
<InfoItem label="To Order Units" value={formatNumber(product.toOrderUnits)} />
|
||||
|
||||
@@ -119,7 +119,6 @@ const BASE_FILTER_OPTIONS: FilterOption[] = [
|
||||
{ id: "currentPrice", label: "Current Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "currentRegularPrice", label: "Regular Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "currentCostPrice", label: "Cost Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "currentLandingCostPrice", label: "Landing Cost", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
|
||||
// Valuation Group
|
||||
{ id: "currentStockCost", label: "Current Stock Cost", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
|
||||
@@ -95,7 +95,6 @@ export const AVAILABLE_COLUMNS: ColumnDef[] = [
|
||||
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentCostPrice', label: 'Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentLandingCostPrice', label: 'Landing Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentStockRetail', label: 'Stock Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentStockGross', label: 'Stock Gross', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
|
||||
170
inventory/src/components/purchase-orders/PipelineCard.tsx
Normal file
170
inventory/src/components/purchase-orders/PipelineCard.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { AlertTriangle, Package, Clock } from 'lucide-react';
|
||||
|
||||
interface Arrival {
|
||||
week: string;
|
||||
poCount: number;
|
||||
expectedValue: number;
|
||||
vendorCount: number;
|
||||
}
|
||||
|
||||
interface PipelineData {
|
||||
arrivals: Arrival[];
|
||||
overdue: { count: number; value: number };
|
||||
summary: { totalOpenPOs: number; totalOnOrderValue: number; vendorCount: number };
|
||||
}
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}k`;
|
||||
return `$${Math.round(value)}`;
|
||||
}
|
||||
|
||||
function formatWeek(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export default function PipelineCard() {
|
||||
const [data, setData] = useState<PipelineData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/purchase-orders/pipeline')
|
||||
.then(res => res.ok ? res.json() : Promise.reject('Failed'))
|
||||
.then(setData)
|
||||
.catch(err => console.error('Pipeline fetch error:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Incoming Pipeline</h3>
|
||||
<div className="h-[200px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading pipeline...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Incoming Pipeline</h3>
|
||||
<p className="text-sm text-destructive">Failed to load pipeline data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentWeekStart = new Date(now);
|
||||
currentWeekStart.setDate(now.getDate() - now.getDay() + 1); // Monday
|
||||
const currentWeekStr = currentWeekStart.toISOString().split('T')[0];
|
||||
|
||||
// Split arrivals into overdue vs upcoming
|
||||
const chartData = data.arrivals.map(a => ({
|
||||
...a,
|
||||
label: formatWeek(a.week),
|
||||
isOverdue: new Date(a.week) < new Date(currentWeekStr),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Incoming Pipeline</h3>
|
||||
<p className="text-xs text-muted-foreground">Expected PO arrivals by week</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary stats row */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="flex items-center gap-2 rounded-lg border p-2.5">
|
||||
<Package className="h-4 w-4 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Open POs</p>
|
||||
<p className="text-sm font-bold">{data.summary.totalOpenPOs}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-lg border p-2.5">
|
||||
<Clock className="h-4 w-4 text-emerald-500" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">On Order</p>
|
||||
<p className="text-sm font-bold">{formatCurrency(data.summary.totalOnOrderValue)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{data.overdue.count > 0 ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50/50 dark:border-red-900/50 dark:bg-red-950/20 p-2.5">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
<div>
|
||||
<p className="text-xs text-red-600 dark:text-red-400">Overdue</p>
|
||||
<p className="text-sm font-bold text-red-600 dark:text-red-400">
|
||||
{data.overdue.count} POs ({formatCurrency(data.overdue.value)})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg border p-2.5">
|
||||
<AlertTriangle className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Overdue</p>
|
||||
<p className="text-sm font-bold text-green-600">None</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrivals chart */}
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-[180px] flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">No expected arrivals scheduled</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={formatCurrency} tick={{ fontSize: 11 }} width={55} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as Arrival & { isOverdue: boolean };
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">
|
||||
Week of {formatWeek(d.week)}
|
||||
{d.isOverdue && <span className="text-red-500 ml-1">(overdue)</span>}
|
||||
</p>
|
||||
<p>{d.poCount} purchase orders</p>
|
||||
<p>Expected value: {formatCurrency(d.expectedValue)}</p>
|
||||
<p>{d.vendorCount} vendors</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="expectedValue" name="Expected Value" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell
|
||||
key={i}
|
||||
fill={entry.isOverdue ? '#ef4444' : '#2563eb'}
|
||||
opacity={0.8}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +1,161 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs';
|
||||
import { ProfitAnalysis } from '../components/analytics/ProfitAnalysis';
|
||||
import { VendorPerformance } from '../components/analytics/VendorPerformance';
|
||||
import { StockAnalysis } from '../components/analytics/StockAnalysis';
|
||||
import { PriceAnalysis } from '../components/analytics/PriceAnalysis';
|
||||
import { CategoryPerformance } from '../components/analytics/CategoryPerformance';
|
||||
import { InventoryValueTrend } from '../components/analytics/InventoryValueTrend';
|
||||
import { InventoryFlow } from '../components/analytics/InventoryFlow';
|
||||
import { InventoryTrends } from '../components/analytics/InventoryTrends';
|
||||
import { PortfolioAnalysis } from '../components/analytics/PortfolioAnalysis';
|
||||
import { CapitalEfficiency } from '../components/analytics/CapitalEfficiency';
|
||||
import { StockHealth } from '../components/analytics/StockHealth';
|
||||
import { AgingSellThrough } from '../components/analytics/AgingSellThrough';
|
||||
import { StockoutRisk } from '../components/analytics/StockoutRisk';
|
||||
import { SeasonalPatterns } from '../components/analytics/SeasonalPatterns';
|
||||
import { DiscountImpact } from '../components/analytics/DiscountImpact';
|
||||
import { GrowthMomentum } from '../components/analytics/GrowthMomentum';
|
||||
import config from '../config';
|
||||
import { motion } from 'motion/react';
|
||||
import { DollarSign, RefreshCw, TrendingUp, Calendar } from 'lucide-react';
|
||||
import { formatCurrency } from '../utils/formatCurrency';
|
||||
|
||||
interface AnalyticsStats {
|
||||
profitMargin: number;
|
||||
averageMarkup: number;
|
||||
stockTurnoverRate: number;
|
||||
vendorCount: number;
|
||||
categoryCount: number;
|
||||
averageOrderValue: number;
|
||||
interface InventorySummary {
|
||||
stockInvestment: number;
|
||||
onOrderValue: number;
|
||||
inventoryTurns: number;
|
||||
gmroi: number;
|
||||
avgStockCoverDays: number;
|
||||
productsInStock: number;
|
||||
deadStockProducts: number;
|
||||
deadStockValue: number;
|
||||
}
|
||||
|
||||
export function Analytics() {
|
||||
const { data: stats, isLoading: statsLoading } = useQuery<AnalyticsStats>({
|
||||
queryKey: ['analytics-stats'],
|
||||
const { data: summary, isLoading, isError } = useQuery<InventorySummary>({
|
||||
queryKey: ['inventory-summary'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/stats`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch analytics stats');
|
||||
}
|
||||
const response = await fetch(`${config.apiUrl}/analytics/inventory-summary`);
|
||||
if (!response.ok) throw new Error('Failed to fetch inventory summary');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (statsLoading || !stats) {
|
||||
return <div className="p-8">Loading analytics...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div layout className="flex-1 space-y-4 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<motion.div layout className="flex-1 space-y-6 p-8 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Analytics</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* KPI Summary Cards */}
|
||||
{isError && (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm text-destructive">Failed to load inventory summary</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Overall Profit Margin
|
||||
</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Stock Investment</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.profitMargin.toFixed(1)}%
|
||||
</div>
|
||||
{isLoading || !summary ? (
|
||||
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{formatCurrency(summary.stockInvestment)}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatCurrency(summary.onOrderValue)} on order
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Markup
|
||||
</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Inventory Turns</CardTitle>
|
||||
<RefreshCw className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.averageMarkup.toFixed(1)}%
|
||||
</div>
|
||||
{isLoading || !summary ? (
|
||||
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{summary.inventoryTurns.toFixed(1)}x</div>
|
||||
<p className="text-xs text-muted-foreground">annualized (30d basis)</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Stock Turnover Rate
|
||||
</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">GMROI</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.stockTurnoverRate.toFixed(2)}x
|
||||
</div>
|
||||
{isLoading || !summary ? (
|
||||
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{summary.gmroi.toFixed(2)}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
annualized profit per $ invested
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Median Stock Cover</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading || !summary ? (
|
||||
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{Math.round(summary.avgStockCoverDays)} days</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{summary.productsInStock.toLocaleString()} products in stock
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="profit" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-5 lg:w-[600px]">
|
||||
<TabsTrigger value="profit">Profit</TabsTrigger>
|
||||
<TabsTrigger value="vendors">Vendors</TabsTrigger>
|
||||
<TabsTrigger value="stock">Stock</TabsTrigger>
|
||||
<TabsTrigger value="pricing">Pricing</TabsTrigger>
|
||||
<TabsTrigger value="categories">Categories</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Section 2: Inventory Value Over Time */}
|
||||
<InventoryValueTrend />
|
||||
|
||||
<TabsContent value="profit" className="space-y-4">
|
||||
<ProfitAnalysis />
|
||||
</TabsContent>
|
||||
{/* Section 3: Inventory Flow — Receiving vs Selling */}
|
||||
<InventoryFlow />
|
||||
|
||||
<TabsContent value="vendors" className="space-y-4">
|
||||
<VendorPerformance />
|
||||
</TabsContent>
|
||||
{/* Section 4: Daily Sales Activity & Stockouts */}
|
||||
<InventoryTrends />
|
||||
|
||||
<TabsContent value="stock" className="space-y-4">
|
||||
<StockAnalysis />
|
||||
</TabsContent>
|
||||
{/* Section 5: ABC Portfolio Analysis */}
|
||||
<PortfolioAnalysis />
|
||||
|
||||
<TabsContent value="pricing" className="space-y-4">
|
||||
<PriceAnalysis />
|
||||
</TabsContent>
|
||||
{/* Section 6: Capital Efficiency */}
|
||||
<CapitalEfficiency />
|
||||
|
||||
<TabsContent value="categories" className="space-y-4">
|
||||
<CategoryPerformance />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* Section 7: Demand & Stock Health */}
|
||||
<StockHealth />
|
||||
|
||||
{/* Section 8: Aging & Sell-Through */}
|
||||
<AgingSellThrough />
|
||||
|
||||
{/* Section 9: Reorder Risk */}
|
||||
<StockoutRisk />
|
||||
|
||||
{/* Section 10: Seasonal Patterns */}
|
||||
<SeasonalPatterns />
|
||||
|
||||
{/* Section 11: Discount Impact */}
|
||||
<DiscountImpact />
|
||||
|
||||
{/* Section 12: YoY Growth Momentum */}
|
||||
<GrowthMomentum />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
908
inventory/src/pages/BulkEdit.tsx
Normal file
908
inventory/src/pages/BulkEdit.tsx
Normal file
@@ -0,0 +1,908 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Sparkles, Save } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
PaginationEllipsis,
|
||||
} from "@/components/ui/pagination";
|
||||
import { ProductSearch } from "@/components/product-editor/ProductSearch";
|
||||
import {
|
||||
BulkEditRow,
|
||||
FIELD_OPTIONS,
|
||||
AI_FIELDS,
|
||||
INITIAL_ROW_STATE,
|
||||
getFieldValue,
|
||||
getSubmitFieldKey,
|
||||
type BulkEditFieldChoice,
|
||||
type RowAiState,
|
||||
} from "@/components/bulk-edit/BulkEditRow";
|
||||
import { submitProductEdit } from "@/services/productEditor";
|
||||
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types";
|
||||
|
||||
const PER_PAGE = 20;
|
||||
const PROD_IMG_HOST = "https://sbing.com";
|
||||
|
||||
/** Strip all HTML tags for use in plain text contexts */
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, "");
|
||||
}
|
||||
|
||||
export default function BulkEdit() {
|
||||
// Product loading state (mirrors ProductEditor)
|
||||
const [allProducts, setAllProducts] = useState<SearchProduct[]>([]);
|
||||
const [fieldOptions, setFieldOptions] = useState<FieldOptions | null>(null);
|
||||
const [isLoadingOptions, setIsLoadingOptions] = useState(true);
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
||||
const [page, _setPage] = useState(1);
|
||||
const topRef = useRef<HTMLDivElement>(null);
|
||||
const setPage = useCallback((v: number | ((p: number) => number)) => {
|
||||
_setPage(v);
|
||||
setTimeout(() => topRef.current?.scrollIntoView({ behavior: "smooth" }), 0);
|
||||
}, []);
|
||||
const [activeTab, setActiveTab] = useState("new");
|
||||
const [loadedTab, setLoadedTab] = useState<string | null>(null);
|
||||
|
||||
// Line picker state
|
||||
const [lineCompany, setLineCompany] = useState<string>("");
|
||||
const [lineLine, setLineLine] = useState<string>("");
|
||||
const [lineSubline, setLineSubline] = useState<string>("");
|
||||
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
|
||||
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
|
||||
const [isLoadingLines, setIsLoadingLines] = useState(false);
|
||||
const [isLoadingSublines, setIsLoadingSublines] = useState(false);
|
||||
|
||||
// Landing extras state
|
||||
const [landingExtras, setLandingExtras] = useState<Record<string, LandingExtra[]>>({});
|
||||
const [isLoadingExtras, setIsLoadingExtras] = useState(false);
|
||||
const [activeLandingItem, setActiveLandingItem] = useState<string | null>(null);
|
||||
|
||||
// Abort controller
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Bulk edit state
|
||||
const [selectedField, setSelectedField] = useState<BulkEditFieldChoice>("description");
|
||||
const [aiStates, setAiStates] = useState<Map<number, RowAiState>>(new Map());
|
||||
const [productImages, setProductImages] = useState<Map<number, string | null>>(new Map());
|
||||
|
||||
// Validation progress
|
||||
const [validationProgress, setValidationProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
// Save progress
|
||||
const [saveProgress, setSaveProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
const isAiField = AI_FIELDS.includes(selectedField);
|
||||
|
||||
const totalPages = Math.ceil(allProducts.length / PER_PAGE);
|
||||
const pageProducts = useMemo(
|
||||
() => allProducts.slice((page - 1) * PER_PAGE, page * PER_PAGE),
|
||||
[allProducts, page]
|
||||
);
|
||||
|
||||
// Get select options for the current field
|
||||
const currentFieldSelectOptions = useMemo((): FieldOption[] | undefined => {
|
||||
if (!fieldOptions) return undefined;
|
||||
switch (selectedField) {
|
||||
case "tax_cat": return fieldOptions.taxCategories;
|
||||
case "size_cat": return fieldOptions.sizes;
|
||||
case "ship_restrictions": return fieldOptions.shippingRestrictions;
|
||||
default: return undefined;
|
||||
}
|
||||
}, [fieldOptions, selectedField]);
|
||||
|
||||
// Load field options on mount (but don't auto-load products)
|
||||
useEffect(() => {
|
||||
axios
|
||||
.get("/api/import/field-options")
|
||||
.then((res) => setFieldOptions(res.data))
|
||||
.catch((err) => {
|
||||
console.error("Failed to load field options:", err);
|
||||
toast.error("Failed to load field options");
|
||||
})
|
||||
.finally(() => setIsLoadingOptions(false));
|
||||
}, []);
|
||||
|
||||
// Load lines when company changes
|
||||
useEffect(() => {
|
||||
setLineLine("");
|
||||
setLineSubline("");
|
||||
setLineOptions([]);
|
||||
setSublineOptions([]);
|
||||
if (!lineCompany) return;
|
||||
setIsLoadingLines(true);
|
||||
axios
|
||||
.get(`/api/import/product-lines/${lineCompany}`)
|
||||
.then((res) => setLineOptions(res.data))
|
||||
.catch(() => setLineOptions([]))
|
||||
.finally(() => setIsLoadingLines(false));
|
||||
}, [lineCompany]);
|
||||
|
||||
// Load sublines when line changes
|
||||
useEffect(() => {
|
||||
setLineSubline("");
|
||||
setSublineOptions([]);
|
||||
if (!lineLine) return;
|
||||
setIsLoadingSublines(true);
|
||||
axios
|
||||
.get(`/api/import/sublines/${lineLine}`)
|
||||
.then((res) => setSublineOptions(res.data))
|
||||
.catch(() => setSublineOptions([]))
|
||||
.finally(() => setIsLoadingSublines(false));
|
||||
}, [lineLine]);
|
||||
|
||||
const loadedPids = useMemo(
|
||||
() => new Set(allProducts.map((p) => Number(p.pid))),
|
||||
[allProducts]
|
||||
);
|
||||
|
||||
// ── Product loading (same patterns as ProductEditor) ──
|
||||
|
||||
const handleSearchSelect = useCallback((product: SearchProduct) => {
|
||||
setAllProducts((prev) => {
|
||||
if (prev.some((p) => p.pid === product.pid)) return prev;
|
||||
return [product, ...prev];
|
||||
});
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleNewSearch = useCallback(() => {
|
||||
setAllProducts([]);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleLoadAllSearch = useCallback(async (pids: number[]) => {
|
||||
const hadExisting = allProducts.length > 0;
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
const res = await axios.get("/api/import/search-products", {
|
||||
params: { pid: pids.join(",") },
|
||||
});
|
||||
const fetched = res.data as SearchProduct[];
|
||||
setAllProducts((prev) => {
|
||||
const existingPids = new Set(prev.map((p) => p.pid));
|
||||
const newProducts = fetched.filter((p) => !existingPids.has(p.pid));
|
||||
return [...prev, ...newProducts];
|
||||
});
|
||||
setPage(1);
|
||||
if (fetched.length > 1) {
|
||||
toast.success(
|
||||
hadExisting
|
||||
? `Loaded remaining ${fetched.length} products`
|
||||
: "Loaded all products"
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to load products");
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadFeedProducts = useCallback(async (endpoint: string, label: string) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setAllProducts([]);
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
const res = await axios.get(`/api/import/${endpoint}`, { signal: controller.signal });
|
||||
setAllProducts(res.data);
|
||||
setPage(1);
|
||||
toast.success(`Loaded ${res.data.length} ${label} products`);
|
||||
} catch (e) {
|
||||
if (!axios.isCancel(e)) toast.error(`Failed to load ${label} products`);
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadLandingExtras = useCallback(async (catId: number, tabKey: string) => {
|
||||
if (landingExtras[tabKey]) return;
|
||||
setIsLoadingExtras(true);
|
||||
try {
|
||||
const res = await axios.get("/api/import/landing-extras", {
|
||||
params: { catId, sid: 0 },
|
||||
});
|
||||
setLandingExtras((prev) => ({ ...prev, [tabKey]: res.data }));
|
||||
} catch {
|
||||
console.error("Failed to load landing extras");
|
||||
} finally {
|
||||
setIsLoadingExtras(false);
|
||||
}
|
||||
}, [landingExtras]);
|
||||
|
||||
const handleLandingClick = useCallback(async (extra: LandingExtra) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setActiveLandingItem(extra.path);
|
||||
setAllProducts([]);
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
const res = await axios.get("/api/import/path-products", {
|
||||
params: { path: extra.path },
|
||||
signal: controller.signal,
|
||||
});
|
||||
setAllProducts(res.data);
|
||||
setPage(1);
|
||||
toast.success(`Loaded ${res.data.length} products for ${stripHtml(extra.name)}`);
|
||||
} catch (e) {
|
||||
if (!axios.isCancel(e)) toast.error("Failed to load products for " + stripHtml(extra.name));
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
setActiveLandingItem(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTabChange = useCallback((tab: string) => {
|
||||
setActiveTab(tab);
|
||||
if (tab === "new" && loadedTab !== "new") {
|
||||
setLoadedTab("new");
|
||||
loadFeedProducts("new-products", "new");
|
||||
loadLandingExtras(-2, "new");
|
||||
} else if (tab === "preorder" && loadedTab !== "preorder") {
|
||||
setLoadedTab("preorder");
|
||||
loadFeedProducts("preorder-products", "pre-order");
|
||||
loadLandingExtras(-16, "preorder");
|
||||
} else if (tab === "hidden" && loadedTab !== "hidden") {
|
||||
setLoadedTab("hidden");
|
||||
loadFeedProducts("hidden-new-products", "hidden");
|
||||
} else if (tab === "search" || tab === "by-line") {
|
||||
abortRef.current?.abort();
|
||||
setAllProducts([]);
|
||||
setPage(1);
|
||||
}
|
||||
}, [loadedTab, loadFeedProducts, loadLandingExtras]);
|
||||
|
||||
const loadLineProducts = useCallback(async () => {
|
||||
if (!lineCompany || !lineLine) return;
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setAllProducts([]);
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
const params: Record<string, string> = { company: lineCompany, line: lineLine };
|
||||
if (lineSubline) params.subline = lineSubline;
|
||||
const res = await axios.get("/api/import/line-products", { params, signal: controller.signal });
|
||||
setAllProducts(res.data);
|
||||
setPage(1);
|
||||
toast.success(`Loaded ${res.data.length} products`);
|
||||
} catch (e) {
|
||||
if (!axios.isCancel(e)) toast.error("Failed to load line products");
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
}, [lineCompany, lineLine, lineSubline]);
|
||||
|
||||
// ── Image loading ──
|
||||
|
||||
// Load first image for current page products
|
||||
useEffect(() => {
|
||||
const pidsNeedingImages = pageProducts
|
||||
.filter((p) => !productImages.has(p.pid))
|
||||
.map((p) => p.pid);
|
||||
|
||||
if (pidsNeedingImages.length === 0) return;
|
||||
|
||||
pidsNeedingImages.forEach((pid) => {
|
||||
axios
|
||||
.get(`/api/import/product-images/${pid}`)
|
||||
.then((res) => {
|
||||
const images = res.data;
|
||||
let url: string | null = null;
|
||||
if (Array.isArray(images) && images.length > 0) {
|
||||
// Get smallest size for thumbnail
|
||||
const first = images[0];
|
||||
const sizes = first.sizes || {};
|
||||
const smallKey = Object.keys(sizes).find((k) => k.includes("175") || k.includes("small"));
|
||||
const anyKey = Object.keys(sizes)[0];
|
||||
const chosen = sizes[smallKey ?? anyKey];
|
||||
url = chosen?.url ?? null;
|
||||
}
|
||||
setProductImages((prev) => new Map(prev).set(pid, url));
|
||||
})
|
||||
.catch(() => {
|
||||
setProductImages((prev) => new Map(prev).set(pid, null));
|
||||
});
|
||||
});
|
||||
}, [pageProducts, productImages]);
|
||||
|
||||
// ── AI Validation ──
|
||||
|
||||
const triggerValidation = useCallback(
|
||||
(products: SearchProduct[]) => {
|
||||
if (!isAiField) return;
|
||||
|
||||
const total = products.length;
|
||||
let done = 0;
|
||||
|
||||
// Mark all as validating
|
||||
setAiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
products.forEach((p) => {
|
||||
const existing = next.get(p.pid) ?? { ...INITIAL_ROW_STATE };
|
||||
next.set(p.pid, { ...existing, status: "validating" });
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
setValidationProgress({ done: 0, total });
|
||||
|
||||
const endpoint =
|
||||
selectedField === "name"
|
||||
? "/api/ai/validate/inline/name"
|
||||
: "/api/ai/validate/inline/description";
|
||||
|
||||
// Fire all requests at once
|
||||
products.forEach(async (product) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (selectedField === "name") {
|
||||
payload.name = product.title;
|
||||
payload.company_name = product.brand;
|
||||
payload.company_id = product.brand_id;
|
||||
payload.line_name = product.line;
|
||||
payload.subline_name = product.subline;
|
||||
// Gather sibling names from products in same brand + line
|
||||
const siblings = allProducts
|
||||
.filter(
|
||||
(p) =>
|
||||
p.pid !== product.pid &&
|
||||
p.brand_id === product.brand_id &&
|
||||
p.line_id === product.line_id
|
||||
)
|
||||
.map((p) => p.title)
|
||||
.filter(Boolean);
|
||||
if (siblings.length > 0) payload.siblingNames = siblings;
|
||||
} else {
|
||||
payload.name = product.title;
|
||||
payload.description = product.description ?? "";
|
||||
payload.company_name = product.brand;
|
||||
payload.company_id = product.brand_id;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ product: payload }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
setAiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(product.pid) ?? { ...INITIAL_ROW_STATE };
|
||||
next.set(product.pid, {
|
||||
...existing,
|
||||
status: "done",
|
||||
result: {
|
||||
isValid: result.isValid ?? true,
|
||||
suggestion: result.suggestion || null,
|
||||
issues: result.issues || [],
|
||||
},
|
||||
editedSuggestion: result.suggestion || null,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Validation error for PID ${product.pid}:`, err);
|
||||
setAiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(product.pid) ?? { ...INITIAL_ROW_STATE };
|
||||
next.set(product.pid, {
|
||||
...existing,
|
||||
status: "done",
|
||||
result: { isValid: true, issues: [] },
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
done++;
|
||||
setValidationProgress((prev) =>
|
||||
prev ? { ...prev, done } : null
|
||||
);
|
||||
if (done >= total) {
|
||||
// Clear progress after a short delay
|
||||
setTimeout(() => setValidationProgress(null), 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
[selectedField, isAiField, allProducts]
|
||||
);
|
||||
|
||||
const handleValidateAll = useCallback(() => {
|
||||
if (!isAiField) return;
|
||||
triggerValidation(pageProducts);
|
||||
}, [isAiField, pageProducts, triggerValidation]);
|
||||
|
||||
// ── Row actions ──
|
||||
|
||||
const handleAccept = useCallback((pid: number, value: string) => {
|
||||
setAiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(pid) ?? { ...INITIAL_ROW_STATE };
|
||||
next.set(pid, {
|
||||
...existing,
|
||||
decision: "accepted",
|
||||
editedSuggestion: value,
|
||||
manualEdit: value,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback((pid: number) => {
|
||||
setAiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(pid) ?? { ...INITIAL_ROW_STATE };
|
||||
next.set(pid, { ...existing, decision: "dismissed" });
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleManualEdit = useCallback((pid: number, value: string) => {
|
||||
setAiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(pid) ?? { ...INITIAL_ROW_STATE };
|
||||
next.set(pid, { ...existing, manualEdit: value });
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleEditSuggestion = useCallback((pid: number, value: string) => {
|
||||
setAiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(pid) ?? { ...INITIAL_ROW_STATE };
|
||||
next.set(pid, { ...existing, editedSuggestion: value });
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Save ──
|
||||
|
||||
const getChangedRows = useCallback((): { pid: number; value: string }[] => {
|
||||
const changed: { pid: number; value: string }[] = [];
|
||||
for (const product of allProducts) {
|
||||
const state = aiStates.get(product.pid);
|
||||
if (!state) continue;
|
||||
|
||||
// Accepted AI suggestion
|
||||
if (state.decision === "accepted" && state.editedSuggestion != null) {
|
||||
if (state.saveStatus !== "saved") {
|
||||
changed.push({ pid: product.pid, value: state.editedSuggestion });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Manual edit (non-AI fields or user-modified field)
|
||||
if (state.manualEdit != null) {
|
||||
const original = getFieldValue(product, selectedField);
|
||||
if (state.manualEdit !== original && state.saveStatus !== "saved") {
|
||||
changed.push({ pid: product.pid, value: state.manualEdit });
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}, [allProducts, aiStates, selectedField]);
|
||||
|
||||
const changedCount = useMemo(() => getChangedRows().length, [getChangedRows]);
|
||||
|
||||
const handleSaveAll = useCallback(async () => {
|
||||
const rows = getChangedRows();
|
||||
if (rows.length === 0) {
|
||||
toast.info("No changes to save");
|
||||
return;
|
||||
}
|
||||
|
||||
const submitKey = getSubmitFieldKey(selectedField);
|
||||
let done = 0;
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
setSaveProgress({ done: 0, total: rows.length });
|
||||
|
||||
for (const row of rows) {
|
||||
// Mark as saving
|
||||
setAiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(row.pid) ?? { ...INITIAL_ROW_STATE };
|
||||
next.set(row.pid, { ...existing, saveStatus: "saving", saveError: null });
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await submitProductEdit({
|
||||
pid: row.pid,
|
||||
changes: { [submitKey]: row.value },
|
||||
environment: "prod",
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
setAiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(row.pid) ?? { ...INITIAL_ROW_STATE };
|
||||
next.set(row.pid, { ...existing, saveStatus: "saved" });
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
errorCount++;
|
||||
const errorMsg = result.message || "Save failed";
|
||||
setAiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(row.pid) ?? { ...INITIAL_ROW_STATE };
|
||||
next.set(row.pid, { ...existing, saveStatus: "error", saveError: errorMsg });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
errorCount++;
|
||||
const errorMsg = err instanceof Error ? err.message : "Save failed";
|
||||
setAiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(row.pid) ?? { ...INITIAL_ROW_STATE };
|
||||
next.set(row.pid, { ...existing, saveStatus: "error", saveError: errorMsg });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
done++;
|
||||
setSaveProgress({ done, total: rows.length });
|
||||
}
|
||||
|
||||
setTimeout(() => setSaveProgress(null), 500);
|
||||
|
||||
if (errorCount === 0) {
|
||||
toast.success(`Saved ${successCount} product${successCount === 1 ? "" : "s"}`);
|
||||
} else {
|
||||
toast.error(`Saved ${successCount}, failed ${errorCount}`);
|
||||
}
|
||||
}, [getChangedRows, selectedField]);
|
||||
|
||||
// ── Clear AI states when field changes ──
|
||||
|
||||
const handleFieldChange = useCallback((field: BulkEditFieldChoice) => {
|
||||
setSelectedField(field);
|
||||
setAiStates(new Map());
|
||||
}, []);
|
||||
|
||||
// ── Landing extras render ──
|
||||
|
||||
const renderLandingExtras = (tabKey: string) => {
|
||||
const extras = landingExtras[tabKey];
|
||||
if (!extras || extras.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 items-start">
|
||||
{extras.map((extra) => (
|
||||
<button
|
||||
key={extra.extra_id}
|
||||
onClick={() => handleLandingClick(extra)}
|
||||
disabled={activeLandingItem === extra.path}
|
||||
className="flex-shrink-0 group relative w-28 text-left"
|
||||
>
|
||||
<div className="aspect-square w-full overflow-hidden rounded-lg border bg-card hover:bg-accent transition-colors relative">
|
||||
{extra.image && (
|
||||
<img
|
||||
src={extra.image.startsWith("/") ? PROD_IMG_HOST + extra.image : extra.image}
|
||||
alt={stripHtml(extra.name)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
{activeLandingItem === extra.path && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/60">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-1 text-center">
|
||||
{(() => {
|
||||
const parts = extra.name.split(/<br\s*\/?>/i).map(stripHtml);
|
||||
return (
|
||||
<div className="text-xs leading-snug">
|
||||
{parts[0] && <span className="font-semibold">{parts[0]}</span>}
|
||||
{parts[1] && <><br /><span className="font-normal">{parts[1]}</span></>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Pagination ──
|
||||
|
||||
const renderPagination = () => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | "ellipsis")[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (page > 3) pages.push("ellipsis");
|
||||
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (page < totalPages - 2) pages.push("ellipsis");
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
className={page === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{getPageNumbers().map((p, i) =>
|
||||
p === "ellipsis" ? (
|
||||
<PaginationItem key={`e${i}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
isActive={p === page}
|
||||
onClick={() => setPage(p)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
className={page === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoadingOptions) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 max-w-5xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h1 className="text-2xl font-bold">Bulk Edit</h1>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">Field:</span>
|
||||
<Select value={selectedField} onValueChange={(v) => handleFieldChange(v as BulkEditFieldChoice)}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{opt.label}
|
||||
{opt.ai && <Sparkles className="h-3 w-3 text-purple-500" />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{isAiField && (
|
||||
<Button
|
||||
onClick={handleValidateAll}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={pageProducts.length === 0 || validationProgress !== null}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-1" />
|
||||
Validate Page
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
size="sm"
|
||||
disabled={changedCount === 0 || saveProgress !== null}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
Save{changedCount > 0 ? ` (${changedCount})` : " All"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product loading tabs */}
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="new">New</TabsTrigger>
|
||||
<TabsTrigger value="preorder">Pre-Order</TabsTrigger>
|
||||
<TabsTrigger value="hidden">Hidden (New)</TabsTrigger>
|
||||
<TabsTrigger value="by-line">By Line</TabsTrigger>
|
||||
<TabsTrigger value="search">Search</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="search" className="mt-4">
|
||||
<ProductSearch
|
||||
onSelect={handleSearchSelect}
|
||||
onLoadAll={handleLoadAllSearch}
|
||||
onNewSearch={handleNewSearch}
|
||||
loadedPids={loadedPids}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new" className="mt-4">
|
||||
{isLoadingExtras && !landingExtras["new"] && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading featured lines...
|
||||
</div>
|
||||
)}
|
||||
{renderLandingExtras("new")}
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading new products...
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preorder" className="mt-4">
|
||||
{isLoadingExtras && !landingExtras["preorder"] && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading featured lines...
|
||||
</div>
|
||||
)}
|
||||
{renderLandingExtras("preorder")}
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading pre-order products...
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="hidden" className="mt-4">
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading hidden recently-created products...
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="by-line" className="mt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={lineCompany} onValueChange={setLineCompany}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue placeholder="Select company..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldOptions?.companies.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={lineLine} onValueChange={setLineLine} disabled={!lineCompany || isLoadingLines}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue placeholder={isLoadingLines ? "Loading..." : "Select line..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lineOptions.map((l) => (
|
||||
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{sublineOptions.length > 0 && (
|
||||
<Select value={lineSubline} onValueChange={setLineSubline} disabled={isLoadingSublines}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue placeholder={isLoadingSublines ? "Loading..." : "All sublines"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sublineOptions.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button onClick={loadLineProducts} disabled={!lineLine || isLoadingProducts}>
|
||||
{isLoadingProducts && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Load
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Progress bars */}
|
||||
{validationProgress && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Validating...</span>
|
||||
<span>
|
||||
{validationProgress.done} / {validationProgress.total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(validationProgress.done / validationProgress.total) * 100}
|
||||
className="h-1.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveProgress && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Saving...</span>
|
||||
<span>
|
||||
{saveProgress.done} / {saveProgress.total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(saveProgress.done / saveProgress.total) * 100}
|
||||
className="h-1.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={topRef} />
|
||||
{renderPagination()}
|
||||
|
||||
{/* Product rows */}
|
||||
{pageProducts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{pageProducts.map((product) => (
|
||||
<BulkEditRow
|
||||
key={product.pid}
|
||||
product={product}
|
||||
field={selectedField}
|
||||
state={aiStates.get(product.pid) ?? INITIAL_ROW_STATE}
|
||||
imageUrl={productImages.get(product.pid) ?? null}
|
||||
selectOptions={currentFieldSelectOptions}
|
||||
onAccept={handleAccept}
|
||||
onDismiss={handleDismiss}
|
||||
onManualEdit={handleManualEdit}
|
||||
onEditSuggestion={handleEditSuggestion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderPagination()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,11 +18,11 @@ export function Overview() {
|
||||
</div>
|
||||
|
||||
{/* First row - Stock and Purchase metrics */}
|
||||
<div className="grid gap-4 grid-cols-2">
|
||||
<Card className="col-span-1">
|
||||
<div className="grid gap-4 grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<StockMetrics />
|
||||
</Card>
|
||||
<Card className="col-span-1">
|
||||
<Card className="col-span-3">
|
||||
<PurchaseMetrics />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
@@ -20,10 +21,117 @@ import { ProductSearch } from "@/components/product-editor/ProductSearch";
|
||||
import { ProductEditForm, LAYOUT_ICONS } from "@/components/product-editor/ProductEditForm";
|
||||
import type { LayoutMode } from "@/components/product-editor/ProductEditForm";
|
||||
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
const PER_PAGE = 20;
|
||||
const PROD_IMG_HOST = "https://sbing.com";
|
||||
|
||||
interface FilterSummaryItem {
|
||||
type: string;
|
||||
op: string;
|
||||
v1: string | null;
|
||||
v2: string | null;
|
||||
v1Label: string | null;
|
||||
v2Label: string | null;
|
||||
}
|
||||
|
||||
const OP_SYMBOLS: Record<string, string> = {
|
||||
equals: "=",
|
||||
notequals: "≠",
|
||||
greater: ">",
|
||||
greater_equals: "≥",
|
||||
less: "<",
|
||||
less_equals: "≤",
|
||||
contains: "contains",
|
||||
notcontains: "excludes",
|
||||
begins: "starts with",
|
||||
};
|
||||
|
||||
function formatFilterBadges(filters: FilterSummaryItem[]): { key: string; text: string }[] {
|
||||
// Group same type+op rows so "line=5 OR line=7" becomes "Line = Lawn Fawn, Carta Bella"
|
||||
const groups = new Map<string, { type: string; op: string; v1s: string[]; v2: string | null }>();
|
||||
for (const f of filters) {
|
||||
const key = `${f.type}:${f.op}`;
|
||||
const display = f.v1Label ?? f.v1;
|
||||
if (groups.has(key)) {
|
||||
if (display) groups.get(key)!.v1s.push(display);
|
||||
} else {
|
||||
groups.set(key, { type: f.type, op: f.op, v1s: display ? [display] : [], v2: f.v2Label ?? f.v2 });
|
||||
}
|
||||
}
|
||||
return Array.from(groups.entries()).map(([key, g]) => {
|
||||
const label = FILTER_LABELS[g.type] ?? g.type.replace(/_/g, " ");
|
||||
let text: string;
|
||||
if (["true", "true1"].includes(g.op)) {
|
||||
text = label;
|
||||
} else if (g.op === "false") {
|
||||
text = `not ${label}`;
|
||||
} else if (g.op === "isnull") {
|
||||
text = `${label} is empty`;
|
||||
} else if (g.op === "between" && g.v2) {
|
||||
text = `${label}: ${g.v1s[0] ?? ""}–${g.v2}`;
|
||||
} else {
|
||||
const sym = OP_SYMBOLS[g.op] ?? g.op;
|
||||
text = g.v1s.length ? `${label} ${sym} ${g.v1s.join(", ")}` : label;
|
||||
}
|
||||
return { key, text };
|
||||
});
|
||||
}
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
company: "company",
|
||||
line: "line",
|
||||
subline: "subline",
|
||||
no_company: "no company",
|
||||
no_line: "no line",
|
||||
no_subline: "no subline",
|
||||
artist: "artist",
|
||||
price: "price",
|
||||
default_price: "default price",
|
||||
price_for_sort: "price for sort",
|
||||
salepercent_for_sort: "sale % for sort",
|
||||
"salepercent_for_sort__clearance": "sale % for sort (clearance)",
|
||||
weight: "weight",
|
||||
weight_price_ratio: "weight/price ratio",
|
||||
price_weight_ratio: "price/weight ratio",
|
||||
length: "length",
|
||||
width: "width",
|
||||
height: "height",
|
||||
no_dim: "no dimensions",
|
||||
size_cat: "size category",
|
||||
dimension: "dimension",
|
||||
yarn_weight: "yarn weight",
|
||||
material: "material",
|
||||
hide: "hidden",
|
||||
hide_in_shop: "hidden in shop",
|
||||
discontinued: "discontinued",
|
||||
force_flag: "force flag",
|
||||
exclusive: "exclusive",
|
||||
lock_quantity: "lock qty",
|
||||
show_notify: "show notify",
|
||||
downloadable: "downloadable",
|
||||
usa_only: "usa only",
|
||||
not_clearance: "not clearance",
|
||||
stat_stop: "stats stopped",
|
||||
notnew: "not new",
|
||||
not_backinstock: "not back-in-stock",
|
||||
reorder: "reorder",
|
||||
score: "score",
|
||||
sold_view_score: "sold/view score",
|
||||
visibility_score: "visibility score",
|
||||
health_score: "health score",
|
||||
tax_code: "tax code",
|
||||
investor: "investor",
|
||||
category: "category",
|
||||
theme: "theme",
|
||||
pid: "product id",
|
||||
basket: "basket",
|
||||
vendor: "vendor",
|
||||
vendor_reference: "supplier id",
|
||||
notions_reference: "notions id",
|
||||
itemnumber: "item number",
|
||||
};
|
||||
|
||||
/** Strip all HTML except <b>, </b>, and <br> tags */
|
||||
function sanitizeHtml(html: string): string {
|
||||
return html.replace(/<\/?(?!b>|br\s*\/?>)[^>]*>/gi, "");
|
||||
@@ -40,15 +148,14 @@ export default function ProductEditor() {
|
||||
const [isLoadingOptions, setIsLoadingOptions] = useState(true);
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
||||
const [layoutMode, setLayoutMode] = useState<LayoutMode>("full");
|
||||
const [page, _setPage] = useState(1);
|
||||
const topRef = useRef<HTMLDivElement>(null);
|
||||
const setPage = useCallback((v: number | ((p: number) => number)) => {
|
||||
_setPage(v);
|
||||
setTimeout(() => topRef.current?.scrollIntoView({ behavior: "smooth" }), 0);
|
||||
}, []);
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeTab, setActiveTab] = useState("new");
|
||||
const [loadedTab, setLoadedTab] = useState<string | null>(null);
|
||||
|
||||
// Query picker state
|
||||
const [queryId, setQueryId] = useState<string>("");
|
||||
const [queryStatus, setQueryStatus] = useState<{ id: string; name: string; count: number; filters: FilterSummaryItem[]; unsupported: string[] } | null>(null);
|
||||
|
||||
// Line picker state
|
||||
const [lineCompany, setLineCompany] = useState<string>("");
|
||||
const [lineLine, setLineLine] = useState<string>("");
|
||||
@@ -113,6 +220,11 @@ export default function ProductEditor() {
|
||||
.finally(() => setIsLoadingSublines(false));
|
||||
}, [lineLine]);
|
||||
|
||||
const loadedPids = useMemo(
|
||||
() => new Set(allProducts.map((p) => Number(p.pid))),
|
||||
[allProducts]
|
||||
);
|
||||
|
||||
const handleSearchSelect = useCallback((product: SearchProduct) => {
|
||||
setAllProducts((prev) => {
|
||||
if (prev.some((p) => p.pid === product.pid)) return prev;
|
||||
@@ -121,6 +233,39 @@ export default function ProductEditor() {
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleNewSearch = useCallback(() => {
|
||||
setAllProducts([]);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleLoadAllSearch = useCallback(async (pids: number[]) => {
|
||||
const hadExisting = allProducts.length > 0;
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
const res = await axios.get("/api/import/search-products", {
|
||||
params: { pid: pids.join(",") },
|
||||
});
|
||||
const fetched = res.data as SearchProduct[];
|
||||
setAllProducts((prev) => {
|
||||
const existingPids = new Set(prev.map((p) => p.pid));
|
||||
const newProducts = fetched.filter((p) => !existingPids.has(p.pid));
|
||||
return [...prev, ...newProducts];
|
||||
});
|
||||
setPage(1);
|
||||
if (fetched.length > 1) {
|
||||
toast.success(
|
||||
hadExisting
|
||||
? `Loaded remaining ${fetched.length} products`
|
||||
: "Loaded all products"
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to load products");
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveProduct = useCallback((pid: number) => {
|
||||
setAllProducts((prev) => prev.filter((p) => p.pid !== pid));
|
||||
}, []);
|
||||
@@ -184,6 +329,8 @@ export default function ProductEditor() {
|
||||
// Auto-load when switching tabs
|
||||
const handleTabChange = useCallback((tab: string) => {
|
||||
setActiveTab(tab);
|
||||
setQueryStatus(null);
|
||||
setQueryId("");
|
||||
if (tab === "new" && loadedTab !== "new") {
|
||||
setLoadedTab("new");
|
||||
loadFeedProducts("new-products", "new");
|
||||
@@ -195,6 +342,10 @@ export default function ProductEditor() {
|
||||
} else if (tab === "hidden" && loadedTab !== "hidden") {
|
||||
setLoadedTab("hidden");
|
||||
loadFeedProducts("hidden-new-products", "hidden");
|
||||
} else if (tab === "search" || tab === "by-line" || tab === "by-query") {
|
||||
abortRef.current?.abort();
|
||||
setAllProducts([]);
|
||||
setPage(1);
|
||||
}
|
||||
}, [loadedTab, loadFeedProducts, loadLandingExtras]);
|
||||
|
||||
@@ -219,6 +370,40 @@ export default function ProductEditor() {
|
||||
}
|
||||
}, [lineCompany, lineLine, lineSubline]);
|
||||
|
||||
const loadQueryProducts = useCallback(async () => {
|
||||
const qid = queryId.trim();
|
||||
if (!qid || isNaN(Number(qid))) return;
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setAllProducts([]);
|
||||
setQueryStatus(null);
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
const res = await axios.get("/api/import/query-products", {
|
||||
params: { query_id: qid },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const { results, filters, unsupported } = res.data;
|
||||
setAllProducts(results);
|
||||
setPage(1);
|
||||
setQueryStatus({
|
||||
id: qid,
|
||||
name: res.headers["x-query-name"] || "",
|
||||
count: results.length,
|
||||
filters: filters ?? [],
|
||||
unsupported: unsupported ?? [],
|
||||
});
|
||||
if (unsupported?.length) {
|
||||
toast.warning(`Query #${qid}: ${unsupported.length} unsupported filter(s) removed — results may be broader than expected`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!axios.isCancel(e)) toast.error("Failed to load query products");
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
}, [queryId]);
|
||||
|
||||
const renderLandingExtras = (tabKey: string) => {
|
||||
const extras = landingExtras[tabKey];
|
||||
if (!extras || extras.length === 0) return null;
|
||||
@@ -370,11 +555,75 @@ export default function ProductEditor() {
|
||||
<TabsTrigger value="preorder">Pre-Order</TabsTrigger>
|
||||
<TabsTrigger value="hidden">Hidden (New)</TabsTrigger>
|
||||
<TabsTrigger value="by-line">By Line</TabsTrigger>
|
||||
<TabsTrigger value="by-query">By Query</TabsTrigger>
|
||||
<TabsTrigger value="search">Search</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="search" className="mt-4">
|
||||
<ProductSearch onSelect={handleSearchSelect} />
|
||||
<ProductSearch
|
||||
onSelect={handleSearchSelect}
|
||||
onLoadAll={handleLoadAllSearch}
|
||||
onNewSearch={handleNewSearch}
|
||||
loadedPids={loadedPids}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="by-query" className="mt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Query ID..."
|
||||
value={queryId}
|
||||
onChange={(e) => setQueryId(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") loadQueryProducts(); }}
|
||||
className="w-52"
|
||||
/>
|
||||
<Button onClick={loadQueryProducts} disabled={!queryId.trim() || isNaN(Number(queryId)) || isLoadingProducts}>
|
||||
{isLoadingProducts && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Load
|
||||
</Button>
|
||||
{queryStatus && (
|
||||
<Button variant="outline" size="icon" onClick={loadQueryProducts} disabled={isLoadingProducts} title="Refresh query results">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading products...
|
||||
</div>
|
||||
)}
|
||||
{queryStatus && !isLoadingProducts && (
|
||||
<div className="mt-3 text-sm text-muted-foreground space-y-1.5">
|
||||
<div>
|
||||
Showing {queryStatus.count} product{queryStatus.count !== 1 ? "s" : ""} from query {queryStatus.id}
|
||||
{queryStatus.name ? ` — ${queryStatus.name}` : ""}.{" "}
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product_tool/${queryStatus.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 underline hover:text-foreground"
|
||||
>
|
||||
Open in Product Tool <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
{queryStatus.filters.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
<span className="text-xs">Filters:</span>
|
||||
{formatFilterBadges(queryStatus.filters).map(({ key, text }) => (
|
||||
<span key={key} className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium">
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{queryStatus.unsupported.length > 0 && (
|
||||
<div className="text-amber-600 dark:text-amber-400">
|
||||
{queryStatus.unsupported.length} filter type{queryStatus.unsupported.length !== 1 ? "s" : ""} not supported ({queryStatus.unsupported.join(", ")}). Results may be broader than expected.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new" className="mt-4">
|
||||
@@ -457,10 +706,15 @@ export default function ProductEditor() {
|
||||
Load
|
||||
</Button>
|
||||
</div>
|
||||
{isLoadingProducts && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading line products...
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div ref={topRef} />
|
||||
{renderPagination()}
|
||||
|
||||
{products.length > 0 && fieldOptions && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import CategoryMetricsCard from "../components/purchase-orders/CategoryMetricsCa
|
||||
import PaginationControls from "../components/purchase-orders/PaginationControls";
|
||||
import PurchaseOrdersTable from "../components/purchase-orders/PurchaseOrdersTable";
|
||||
import FilterControls from "../components/purchase-orders/FilterControls";
|
||||
import PipelineCard from "../components/purchase-orders/PipelineCard";
|
||||
|
||||
interface PurchaseOrder {
|
||||
id: number | string;
|
||||
@@ -450,6 +451,10 @@ export default function PurchaseOrders() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<PipelineCard />
|
||||
</div>
|
||||
|
||||
<FilterControls
|
||||
searchInput={searchInput}
|
||||
setSearchInput={setSearchInput}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
export interface ImageChanges {
|
||||
order: (number | string)[];
|
||||
hidden: number[];
|
||||
deleted: number[];
|
||||
added: Record<string, string>; // e.g. { "new-0": "https://..." }
|
||||
show: number[];
|
||||
delete: number[];
|
||||
add: Record<string, string>; // e.g. { "new-0": "https://..." }
|
||||
}
|
||||
|
||||
export interface SubmitProductEditArgs {
|
||||
pid: number;
|
||||
changes: Record<string, unknown>;
|
||||
environment: "dev" | "prod";
|
||||
imageChanges?: ImageChanges;
|
||||
}
|
||||
|
||||
export interface SubmitImageChangesArgs {
|
||||
pid: number;
|
||||
imageChanges: ImageChanges;
|
||||
environment: "dev" | "prod";
|
||||
}
|
||||
|
||||
export interface SubmitProductEditResponse {
|
||||
@@ -31,14 +37,10 @@ export async function submitProductEdit({
|
||||
pid,
|
||||
changes,
|
||||
environment,
|
||||
imageChanges,
|
||||
}: SubmitProductEditArgs): Promise<SubmitProductEditResponse> {
|
||||
const targetUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT;
|
||||
|
||||
const product: Record<string, unknown> = { pid, ...changes };
|
||||
if (imageChanges) {
|
||||
product.image_changes = imageChanges;
|
||||
}
|
||||
const payload = new URLSearchParams();
|
||||
payload.append("products", JSON.stringify([product]));
|
||||
|
||||
@@ -96,3 +98,138 @@ export async function submitProductEdit({
|
||||
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
|
||||
};
|
||||
}
|
||||
|
||||
export type TaxonomyType = "cats" | "themes" | "colors";
|
||||
|
||||
export interface SubmitTaxonomySetArgs {
|
||||
pid: number;
|
||||
type: TaxonomyType;
|
||||
ids: number[];
|
||||
environment: "dev" | "prod";
|
||||
}
|
||||
|
||||
export async function submitTaxonomySet({
|
||||
pid,
|
||||
type,
|
||||
ids,
|
||||
environment,
|
||||
}: SubmitTaxonomySetArgs): Promise<SubmitProductEditResponse> {
|
||||
const base = environment === "dev" ? "/apiv2-test" : "/apiv2";
|
||||
const targetUrl = `${base}/product/${type}/${pid}/set`;
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(ids),
|
||||
};
|
||||
|
||||
if (environment === "dev") {
|
||||
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
|
||||
if (authToken) {
|
||||
fetchOptions.body = JSON.stringify({ ids, auth: authToken });
|
||||
}
|
||||
} else {
|
||||
fetchOptions.credentials = "include";
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(targetUrl, fetchOptions);
|
||||
} catch (networkError) {
|
||||
throw new Error(
|
||||
networkError instanceof Error ? networkError.message : "Network request failed"
|
||||
);
|
||||
}
|
||||
|
||||
const rawBody = await response.text();
|
||||
|
||||
if (isHtmlResponse(rawBody)) {
|
||||
throw new Error(
|
||||
"Backend authentication required. Please ensure you are logged into the backend system."
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawBody);
|
||||
} catch {
|
||||
throw new Error(`Unexpected response from backend (${response.status}).`);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Empty response from backend");
|
||||
}
|
||||
|
||||
const parsedResponse = parsed as Record<string, unknown>;
|
||||
return {
|
||||
success: Boolean(parsedResponse.success),
|
||||
message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined,
|
||||
data: parsedResponse.data,
|
||||
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
|
||||
};
|
||||
}
|
||||
|
||||
const DEV_IMAGE_ENDPOINT = "/apiv2-test/product/image_changes";
|
||||
const PROD_IMAGE_ENDPOINT = "/apiv2/product/image_changes";
|
||||
|
||||
export async function submitImageChanges({
|
||||
pid,
|
||||
imageChanges,
|
||||
environment,
|
||||
}: SubmitImageChangesArgs): Promise<SubmitProductEditResponse> {
|
||||
const targetUrl = environment === "dev" ? DEV_IMAGE_ENDPOINT : PROD_IMAGE_ENDPOINT;
|
||||
|
||||
const body = { pid, image_changes: imageChanges };
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
|
||||
if (environment === "dev") {
|
||||
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
|
||||
if (authToken) {
|
||||
(body as Record<string, unknown>).auth = authToken;
|
||||
fetchOptions.body = JSON.stringify(body);
|
||||
}
|
||||
} else {
|
||||
fetchOptions.credentials = "include";
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(targetUrl, fetchOptions);
|
||||
} catch (networkError) {
|
||||
throw new Error(
|
||||
networkError instanceof Error ? networkError.message : "Network request failed"
|
||||
);
|
||||
}
|
||||
|
||||
const rawBody = await response.text();
|
||||
|
||||
if (isHtmlResponse(rawBody)) {
|
||||
throw new Error(
|
||||
"Backend authentication required. Please ensure you are logged into the backend system."
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawBody);
|
||||
} catch {
|
||||
throw new Error(`Unexpected response from backend (${response.status}).`);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Empty response from backend");
|
||||
}
|
||||
|
||||
const parsedResponse = parsed as Record<string, unknown>;
|
||||
return {
|
||||
success: Boolean(parsedResponse.success),
|
||||
message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined,
|
||||
data: parsedResponse.data,
|
||||
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface Product {
|
||||
price: string; // numeric(15,3)
|
||||
regular_price: string; // numeric(15,3)
|
||||
cost_price: string; // numeric(15,3)
|
||||
landing_cost_price: string | null; // numeric(15,3)
|
||||
barcode: string;
|
||||
vendor: string;
|
||||
vendor_reference: string;
|
||||
@@ -126,7 +125,6 @@ export interface ProductMetric {
|
||||
currentPrice: number | null;
|
||||
currentRegularPrice: number | null;
|
||||
currentCostPrice: number | null;
|
||||
currentLandingCostPrice: number | null;
|
||||
currentStock: number;
|
||||
currentStockCost: number | null;
|
||||
currentStockRetail: number | null;
|
||||
@@ -310,7 +308,6 @@ export type ProductMetricColumnKey =
|
||||
| 'currentPrice'
|
||||
| 'currentRegularPrice'
|
||||
| 'currentCostPrice'
|
||||
| 'currentLandingCostPrice'
|
||||
| 'configSafetyStock'
|
||||
| 'replenishmentUnits'
|
||||
| 'stockCoverInDays'
|
||||
|
||||
6
inventory/src/utils/formatCurrency.ts
Normal file
6
inventory/src/utils/formatCurrency.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function formatCurrency(value: number): string {
|
||||
if (value < 0) return `-${formatCurrency(-value)}`;
|
||||
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}k`;
|
||||
return `$${value.toFixed(0)}`;
|
||||
}
|
||||
15
inventory/src/utils/lifecyclePhases.ts
Normal file
15
inventory/src/utils/lifecyclePhases.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const PHASE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
preorder: { label: "Pre-order", color: "#3B82F6" },
|
||||
launch: { label: "Launch", color: "#22C55E" },
|
||||
decay: { label: "Active", color: "#F59E0B" },
|
||||
mature: { label: "Evergreen", color: "#8B5CF6" },
|
||||
slow_mover: { label: "Slow Mover", color: "#14B8A6" },
|
||||
dormant: { label: "Dormant", color: "#6B7280" },
|
||||
unknown: { label: "Unclassified", color: "#94A3B8" },
|
||||
}
|
||||
|
||||
/** Stacking order for phase area/bar charts (bottom to top) */
|
||||
export const PHASE_KEYS = ["mature", "slow_mover", "decay", "launch", "preorder", "dormant"] as const
|
||||
|
||||
/** Same as PHASE_KEYS but includes the unknown bucket (for sales data where lifecycle_phase can be NULL) */
|
||||
export const PHASE_KEYS_WITH_UNKNOWN = ["mature", "slow_mover", "decay", "launch", "preorder", "dormant", "unknown"] as const
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user