18 Commits

Author SHA1 Message Date
f8b81d2111 Add loading products from PT query to editor, product editor search enhancements 2026-03-18 15:29:00 -04:00
1b836567cd Add category suggestions to product editor, deal with taxonomy embeddings better, fix category badge overflow 2026-03-18 12:40:25 -04:00
39b8faa208 Add draft bulk edit page, enhance product edit form to handle current price and image changes submission, handle product editor taxonomy updates 2026-03-18 11:52:42 -04:00
177f7778b9 Don't validate empty descriptions, other validation enhancements 2026-03-11 16:23:20 -04:00
f887dc6af1 Product editor search enhancements 2026-03-11 15:23:03 -04:00
c344fdc3b8 Fix a few product editor issues, normalize prices on spreadsheet import 2026-03-05 10:45:39 -05:00
ebef903f3b Switch column order in import 2026-02-26 17:00:11 -05:00
16d2399de8 Restore accidentally removed files, a few forecast tweaks 2026-02-24 11:13:19 -05:00
c3e09d5fd1 Add AI name/description validation to product editor 2026-02-17 09:54:37 -05:00
bae8c575bc Overview tweaks 2026-02-13 23:18:45 -05:00
45ded53530 Add in forecasting, lifecycle phases, associated component and script changes 2026-02-13 22:45:18 -05:00
f41b5ab0f6 Clean up inventory overview page 2026-02-09 22:59:34 -05:00
6834a77a80 Updates for new analytics page + add pipeline chart to PO page 2026-02-09 12:32:13 -05:00
38b12c188f Restore accidentally removed files, a few additional import/calculation fixes 2026-02-09 10:19:35 -05:00
6aefc1b40d Optimize orders import 2026-02-09 09:03:21 -05:00
7c41a7f799 Import/metrics calc fixes 2026-02-08 22:44:57 -05:00
12cc7a4639 Fixes for metrics calculations 2026-02-07 21:34:42 -05:00
9b2f9016f6 Redo analytics page 2026-02-07 13:44:51 -05:00
100 changed files with 13097 additions and 4225 deletions

7
.gitignore vendored
View File

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

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

View File

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

View File

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

View File

@@ -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',

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
numpy>=1.24
scipy>=1.10
pandas>=2.0
psycopg2-binary>=2.9
statsmodels>=0.14

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

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

View File

@@ -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),

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

View File

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

View File

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

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

View File

@@ -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>
</>
)}

View File

@@ -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>
</>
)

View File

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

View File

@@ -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>
</>
)

View File

@@ -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>
</>
)

View File

@@ -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>
</>
)

View File

@@ -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>
</>
)

View File

@@ -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>
</>
)

View File

@@ -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>
</>
)

View File

@@ -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>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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",

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)} />

View File

@@ -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"] },

View File

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

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

View File

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

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

View File

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

View File

@@ -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 && (

View File

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

View File

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

View File

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

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

View 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