11 Commits

70 changed files with 7562 additions and 2165 deletions

5
.gitignore vendored
View File

@@ -80,3 +80,8 @@ chat-migration*/
**/chat-migration*/ **/chat-migration*/
chat-migration*/** chat-migration*/**
**/chat-migration*/** **/chat-migration*/**
venv/
venv/**
**/venv/*
**/venv/**

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_price NUMERIC(10, 2),
current_regular_price NUMERIC(10, 2), current_regular_price NUMERIC(10, 2),
current_cost_price NUMERIC(10, 4), -- Increased precision for cost 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 INT NOT NULL DEFAULT 0,
current_stock_cost NUMERIC(14, 4) NOT NULL DEFAULT 0.00, current_stock_cost NUMERIC(14, 4) NOT NULL DEFAULT 0.00,
current_stock_retail 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 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_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_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_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 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_sales_units NUMERIC(10, 2), -- GREATEST(0, -lead_time_closing_stock)
forecast_lost_revenue NUMERIC(14, 4), -- forecast_lost_sales_units * current_price 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 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 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_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 overstocked_retail NUMERIC(14, 4), -- overstocked_units * current_price
is_old_stock BOOLEAN, -- Based on age, last sold, last received, on_order status 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, price NUMERIC(14, 4) NOT NULL,
regular_price NUMERIC(14, 4) NOT NULL, regular_price NUMERIC(14, 4) NOT NULL,
cost_price NUMERIC(14, 4), cost_price NUMERIC(14, 4),
landing_cost_price NUMERIC(14, 4),
barcode TEXT, barcode TEXT,
harmonized_tariff_code TEXT, harmonized_tariff_code TEXT,
updated_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE,

View File

@@ -11,6 +11,7 @@ const RUN_PERIODIC_METRICS = true;
const RUN_BRAND_METRICS = true; const RUN_BRAND_METRICS = true;
const RUN_VENDOR_METRICS = true; const RUN_VENDOR_METRICS = true;
const RUN_CATEGORY_METRICS = true; const RUN_CATEGORY_METRICS = true;
const RUN_LIFECYCLE_FORECASTS = true;
// Maximum execution time for the entire sequence (e.g., 90 minutes) // Maximum execution time for the entire sequence (e.g., 90 minutes)
const MAX_EXECUTION_TIME_TOTAL = 90 * 60 * 1000; const MAX_EXECUTION_TIME_TOTAL = 90 * 60 * 1000;
@@ -592,6 +593,13 @@ async function runAllCalculations() {
historyType: 'product_metrics', historyType: 'product_metrics',
statusModule: '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, run: RUN_PERIODIC_METRICS,
name: 'Periodic Metrics Update', 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 importOrders = require('./import/orders');
const importPurchaseOrders = require('./import/purchase-orders'); const importPurchaseOrders = require('./import/purchase-orders');
const importDailyDeals = require('./import/daily-deals'); const importDailyDeals = require('./import/daily-deals');
const importStockSnapshots = require('./import/stock-snapshots');
dotenv.config({ path: path.join(__dirname, "../.env") }); dotenv.config({ path: path.join(__dirname, "../.env") });
@@ -16,6 +17,7 @@ const IMPORT_PRODUCTS = true;
const IMPORT_ORDERS = true; const IMPORT_ORDERS = true;
const IMPORT_PURCHASE_ORDERS = true; const IMPORT_PURCHASE_ORDERS = true;
const IMPORT_DAILY_DEALS = true; const IMPORT_DAILY_DEALS = true;
const IMPORT_STOCK_SNAPSHOTS = true;
// Add flag for incremental updates // Add flag for incremental updates
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false 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, password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME, database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306, 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: { localDbConfig: {
// PostgreSQL config for local // PostgreSQL config for local
@@ -81,7 +83,8 @@ async function main() {
IMPORT_PRODUCTS, IMPORT_PRODUCTS,
IMPORT_ORDERS, IMPORT_ORDERS,
IMPORT_PURCHASE_ORDERS, IMPORT_PURCHASE_ORDERS,
IMPORT_DAILY_DEALS IMPORT_DAILY_DEALS,
IMPORT_STOCK_SNAPSHOTS
].filter(Boolean).length; ].filter(Boolean).length;
try { try {
@@ -130,10 +133,11 @@ async function main() {
'products_enabled', $3::boolean, 'products_enabled', $3::boolean,
'orders_enabled', $4::boolean, 'orders_enabled', $4::boolean,
'purchase_orders_enabled', $5::boolean, 'purchase_orders_enabled', $5::boolean,
'daily_deals_enabled', $6::boolean 'daily_deals_enabled', $6::boolean,
'stock_snapshots_enabled', $7::boolean
) )
) RETURNING id ) 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; importHistoryId = historyResult.rows[0].id;
} catch (error) { } catch (error) {
console.error("Error creating import history record:", error); console.error("Error creating import history record:", error);
@@ -151,7 +155,8 @@ async function main() {
products: null, products: null,
orders: null, orders: null,
purchaseOrders: null, purchaseOrders: null,
dailyDeals: null dailyDeals: null,
stockSnapshots: null
}; };
let totalRecordsAdded = 0; 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 endTime = Date.now();
const totalElapsedSeconds = Math.round((endTime - startTime) / 1000); const totalElapsedSeconds = Math.round((endTime - startTime) / 1000);
@@ -280,11 +312,13 @@ async function main() {
'orders_result', COALESCE($11::jsonb, 'null'::jsonb), 'orders_result', COALESCE($11::jsonb, 'null'::jsonb),
'purchase_orders_result', COALESCE($12::jsonb, 'null'::jsonb), 'purchase_orders_result', COALESCE($12::jsonb, 'null'::jsonb),
'daily_deals_result', COALESCE($13::jsonb, 'null'::jsonb), 'daily_deals_result', COALESCE($13::jsonb, 'null'::jsonb),
'total_deleted', $14::integer, 'stock_snapshots_enabled', $14::boolean,
'total_skipped', $15::integer, 'stock_snapshots_result', COALESCE($15::jsonb, 'null'::jsonb),
'step_timings', $16::jsonb 'total_deleted', $16::integer,
'total_skipped', $17::integer,
'step_timings', $18::jsonb
) )
WHERE id = $17 WHERE id = $19
`, [ `, [
totalElapsedSeconds, totalElapsedSeconds,
parseInt(totalRecordsAdded), parseInt(totalRecordsAdded),
@@ -299,6 +333,8 @@ async function main() {
JSON.stringify(results.orders), JSON.stringify(results.orders),
JSON.stringify(results.purchaseOrders), JSON.stringify(results.purchaseOrders),
JSON.stringify(results.dailyDeals), JSON.stringify(results.dailyDeals),
IMPORT_STOCK_SNAPSHOTS,
JSON.stringify(results.stockSnapshots),
totalRecordsDeleted, totalRecordsDeleted,
totalRecordsSkipped, totalRecordsSkipped,
JSON.stringify(stepTimings), JSON.stringify(stepTimings),

View File

@@ -58,8 +58,12 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'orders'" "SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'orders'"
); );
const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01'; 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 // First get count of order items - Keep MySQL compatible for production
const [[{ total }]] = await prodConnection.query(` const [[{ total }]] = await prodConnection.query(`
@@ -71,23 +75,18 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
AND o.date_placed IS NOT NULL AND o.date_placed IS NOT NULL
${incrementalUpdate ? ` ${incrementalUpdate ? `
AND ( AND (
o.stamp > ? o.stamp > ?
OR oi.stamp > ? OR oi.stamp > ?
OR EXISTS ( OR EXISTS (
SELECT 1 FROM order_discount_items odi SELECT 1 FROM order_tax_info oti
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 JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id
WHERE oti.order_id = o.order_id WHERE oti.order_id = o.order_id
AND otip.pid = oi.prod_pid AND otip.pid = oi.prod_pid
AND oti.stamp > ? AND oti.stamp > ?
) )
) )
` : ''} ` : ''}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []); `, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
totalOrderItems = total; totalOrderItems = total;
console.log('Orders: Found changes:', totalOrderItems); console.log('Orders: Found changes:', totalOrderItems);
@@ -110,23 +109,18 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
AND o.date_placed IS NOT NULL AND o.date_placed IS NOT NULL
${incrementalUpdate ? ` ${incrementalUpdate ? `
AND ( AND (
o.stamp > ? o.stamp > ?
OR oi.stamp > ? OR oi.stamp > ?
OR EXISTS ( OR EXISTS (
SELECT 1 FROM order_discount_items odi SELECT 1 FROM order_tax_info oti
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 JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id
WHERE oti.order_id = o.order_id WHERE oti.order_id = o.order_id
AND otip.pid = oi.prod_pid AND otip.pid = oi.prod_pid
AND oti.stamp > ? AND oti.stamp > ?
) )
) )
` : ''} ` : ''}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []); `, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
console.log('Orders: Found', orderItems.length, 'order items to process'); console.log('Orders: Found', orderItems.length, 'order items to process');
@@ -540,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) { for (let i = 0; i < orderIds.length; i += METADATA_BATCH_SIZE) {
const batchIds = orderIds.slice(i, 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 processMetadataBatch(batchIds);
await processDiscountsBatch(batchIds); await processDiscountsBatch(batchIds);
await processTaxesBatch(batchIds); await processTaxesBatch(batchIds);
@@ -563,16 +558,36 @@ 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))]; const allOrderPids = [...new Set(orderItems.map(item => item.prod_pid))];
console.log('Orders: Checking', allOrderPids.length, 'unique products'); console.log('Orders: Checking', allOrderPids.length, 'unique products');
const [existingProducts] = allOrderPids.length > 0 ? await localConnection.query( 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] [allOrderPids]
) : [[]]; ) : [{ rows: [] }];
const existingPids = new Set(existingProducts.rows.map(p => p.pid)); 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 // Process in smaller batches
for (let i = 0; i < orderIds.length; i += 2000) { // Increased from 1000 to 2000 for (let i = 0; i < orderIds.length; i += 2000) { // Increased from 1000 to 2000
@@ -597,14 +612,15 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
ELSE 0 ELSE 0
END) as promo_discount_sum, END) as promo_discount_sum,
COALESCE(ot.tax, 0) as total_tax, 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 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_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_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_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_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) 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 SELECT
oi.order_id as order_number, oi.order_id as order_number,
@@ -631,10 +647,11 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
om.customer_name, om.customer_name,
om.status, om.status,
om.canceled, 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 FROM temp_order_items oi
JOIN temp_order_meta om ON oi.order_id = om.order_id 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 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) WHERE oi.order_id = ANY($1)
ORDER BY oi.order_id, oi.pid ORDER BY oi.order_id, oi.pid
`, [subBatchIds]); `, [subBatchIds]);
@@ -768,6 +785,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
DROP TABLE IF EXISTS temp_order_costs; DROP TABLE IF EXISTS temp_order_costs;
DROP TABLE IF EXISTS temp_main_discounts; DROP TABLE IF EXISTS temp_main_discounts;
DROP TABLE IF EXISTS temp_item_discounts; DROP TABLE IF EXISTS temp_item_discounts;
DROP TABLE IF EXISTS temp_product_costs;
`); `);
// Commit final transaction // Commit final transaction

View File

@@ -669,8 +669,13 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
// Setup temporary tables // Setup temporary tables
await setupTemporaryTables(localConnection); 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 // 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 // Get the list of products that need updating
const [products] = await localConnection.query(` const [products] = await localConnection.query(`

View File

@@ -65,8 +65,12 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'purchase_orders'" "SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'purchase_orders'"
); );
const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01'; 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 // Create temp tables for processing
await localConnection.query(` await localConnection.query(`
@@ -254,7 +258,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
OR p.date_estin > ? OR p.date_estin > ?
) )
` : ''} ` : ''}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []); `, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
const totalPOs = poCount[0].total; const totalPOs = poCount[0].total;
console.log(`Found ${totalPOs} relevant purchase orders`); console.log(`Found ${totalPOs} relevant purchase orders`);
@@ -291,7 +295,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
` : ''} ` : ''}
ORDER BY p.po_id ORDER BY p.po_id
LIMIT ${PO_BATCH_SIZE} OFFSET ${offset} LIMIT ${PO_BATCH_SIZE} OFFSET ${offset}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []); `, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
if (poList.length === 0) { if (poList.length === 0) {
allPOsProcessed = true; allPOsProcessed = true;
@@ -426,7 +430,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
OR r.date_created > ? OR r.date_created > ?
) )
` : ''} ` : ''}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime] : []); `, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime] : []);
const totalReceivings = receivingCount[0].total; const totalReceivings = receivingCount[0].total;
console.log(`Found ${totalReceivings} relevant receivings`); console.log(`Found ${totalReceivings} relevant receivings`);
@@ -463,7 +467,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
` : ''} ` : ''}
ORDER BY r.receiving_id ORDER BY r.receiving_id
LIMIT ${PO_BATCH_SIZE} OFFSET ${offset} LIMIT ${PO_BATCH_SIZE} OFFSET ${offset}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime] : []); `, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime] : []);
if (receivingList.length === 0) { if (receivingList.length === 0) {
allReceivingsProcessed = true; 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, 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 // Setup PostgreSQL connection pool for local
const localPool = new Pool(sshConfig.localDbConfig); const localPool = new Pool(sshConfig.localDbConfig);

View File

@@ -214,7 +214,7 @@ BEGIN
-- Final INSERT/UPDATE statement using all the prepared CTEs -- Final INSERT/UPDATE statement using all the prepared CTEs
INSERT INTO public.product_metrics ( INSERT INTO public.product_metrics (
pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable, 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, current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date, 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, date_created, date_first_received, date_last_received, date_first_sold, date_last_sold, age_days,
@@ -242,7 +242,7 @@ BEGIN
SELECT SELECT
-- Select columns in order, joining all CTEs by pid -- 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.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), 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, 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 *** -- *** IMPORTANT: List ALL columns here, ensuring order matches INSERT list ***
-- Update ALL columns to ensure entire row is refreshed -- 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, 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, 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, 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, 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

@@ -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.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.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 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.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, 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) -- Aggregate Returns (Quantity < 0 or Status = Returned)
@@ -63,15 +69,17 @@ BEGIN
GROUP BY r.pid GROUP BY r.pid
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0 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 ( StockData AS (
SELECT SELECT
p.pid, p.pid,
p.stock_quantity, COALESCE(ss.stock_quantity, p.stock_quantity) AS stock_quantity,
COALESCE(p.cost_price, 0.00) as effective_cost_price, 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.price, 0.00) as current_price,
COALESCE(p.regular_price, 0.00) as current_regular_price COALESCE(p.regular_price, 0.00) as current_regular_price
FROM public.products p 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 ( INSERT INTO public.daily_product_snapshots (
snapshot_date, snapshot_date,
@@ -99,9 +107,9 @@ BEGIN
_date AS snapshot_date, _date AS snapshot_date,
COALESCE(sd.pid, rd.pid) AS pid, COALESCE(sd.pid, rd.pid) AS pid,
sd.sku, 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 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_price AS eod_stock_retail,
s.stock_quantity * s.current_regular_price AS eod_stock_gross, s.stock_quantity * s.current_regular_price AS eod_stock_gross,
(s.stock_quantity <= 0) AS stockout_flag, (s.stock_quantity <= 0) AS stockout_flag,
@@ -114,7 +122,7 @@ BEGIN
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 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.cogs, 0.00),
COALESCE(sd.gross_regular_revenue, 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 -- Receiving metrics
COALESCE(rd.units_received, 0), COALESCE(rd.units_received, 0),
COALESCE(rd.cost_received, 0.00), COALESCE(rd.cost_received, 0.00),

View File

@@ -23,21 +23,21 @@ BEGIN
-- Only include products with valid sales data in each time period -- 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, 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.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, 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.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.revenue_30d, 0)) AS revenue_30d,
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d, SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
SUM(COALESCE(pm.profit_30d, 0)) AS profit_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, 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.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, 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_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 FROM public.product_metrics pm
JOIN public.products p ON pm.pid = p.pid JOIN public.products p ON pm.pid = p.pid
GROUP BY brand_group GROUP BY brand_group

View File

@@ -24,21 +24,21 @@ BEGIN
-- Only include products with valid sales data in each time period -- 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, 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.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, 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.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.revenue_30d, 0)) AS revenue_30d,
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d, SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
SUM(COALESCE(pm.profit_30d, 0)) AS profit_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, 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.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, 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_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 FROM public.product_metrics pm
JOIN public.products p ON pm.pid = p.pid JOIN public.products p ON pm.pid = p.pid
WHERE p.vendor IS NOT NULL AND p.vendor <> '' WHERE p.vendor IS NOT NULL AND p.vendor <> ''

View File

@@ -1,6 +1,7 @@
-- Description: Calculates and updates daily aggregated product data. -- Description: Calculates and updates daily aggregated product data.
-- Self-healing: automatically detects and fills gaps in snapshot history. -- Self-healing: detects gaps (missing snapshots), stale data (snapshot
-- Always reprocesses recent days to pick up new orders and data corrections. -- 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. -- Dependencies: Core import tables (products, orders, purchase_orders), calculate_status table.
-- Frequency: Hourly (Run ~5-10 minutes after hourly data import completes). -- Frequency: Hourly (Run ~5-10 minutes after hourly data import completes).
@@ -18,28 +19,26 @@ DECLARE
BEGIN BEGIN
RAISE NOTICE 'Running % script. Start Time: %', _module_name, _start_time; RAISE NOTICE 'Running % script. Start Time: %', _module_name, _start_time;
-- Find the latest existing snapshot date to determine where gaps begin -- Find the latest existing snapshot date (for logging only)
SELECT MAX(snapshot_date) INTO _latest_snapshot SELECT MAX(snapshot_date) INTO _latest_snapshot
FROM public.daily_product_snapshots; FROM public.daily_product_snapshots;
-- Determine how far back to look for gaps, capped at _max_backfill_days -- Always scan the full backfill window to catch holes in the middle,
_backfill_start := GREATEST( -- not just gaps at the end. The gap fill and stale detection queries
COALESCE(_latest_snapshot + 1, CURRENT_DATE - _max_backfill_days), -- need to see the entire range to find missing or outdated snapshots.
CURRENT_DATE - _max_backfill_days _backfill_start := CURRENT_DATE - _max_backfill_days;
);
IF _latest_snapshot IS NULL THEN IF _latest_snapshot IS NULL THEN
RAISE NOTICE 'No existing snapshots found. Backfilling up to % days.', _max_backfill_days; RAISE NOTICE 'No existing snapshots found. Backfilling up to % days.', _max_backfill_days;
ELSIF _backfill_start > _latest_snapshot + 1 THEN
RAISE NOTICE 'Latest snapshot: %. Gap exceeds % day cap — backfilling from %. Use rebuild script for full history.',
_latest_snapshot, _max_backfill_days, _backfill_start;
ELSE ELSE
RAISE NOTICE 'Latest snapshot: %. Checking for gaps from %.', _latest_snapshot, _backfill_start; RAISE NOTICE 'Latest snapshot: %. Scanning from % for gaps and stale data.', _latest_snapshot, _backfill_start;
END IF; END IF;
-- Process all dates that need snapshots: -- Process all dates that need snapshots:
-- 1. Gap fill: dates with orders/receivings but no snapshots (older than recent window) -- 1. Gap fill: dates with orders/receivings but no snapshots (older than recent window)
-- 2. Recent recheck: last N days always reprocessed (picks up new orders, corrections) -- 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 FOR _target_date IN
SELECT d FROM ( SELECT d FROM (
-- Gap fill: find dates with activity but missing snapshots -- Gap fill: find dates with activity but missing snapshots
@@ -55,6 +54,36 @@ BEGIN
SELECT 1 FROM public.daily_product_snapshots dps WHERE dps.snapshot_date = activity_dates.d SELECT 1 FROM public.daily_product_snapshots dps WHERE dps.snapshot_date = activity_dates.d
) )
UNION 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 -- Recent days: always reprocess
SELECT d::date SELECT d::date
FROM generate_series( FROM generate_series(
@@ -66,11 +95,18 @@ BEGIN
ORDER BY d ORDER BY d
LOOP LOOP
_days_processed := _days_processed + 1; _days_processed := _days_processed + 1;
RAISE NOTICE 'Processing date: % [%/%]', _target_date, _days_processed,
_days_processed; -- count not known ahead of time, but shows progress -- 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 -- IMPORTANT: First delete any existing data for this date to prevent duplication
DELETE FROM public.daily_product_snapshots DELETE FROM public.daily_product_snapshots
WHERE snapshot_date = _target_date; WHERE snapshot_date = _target_date;
-- Proceed with calculating daily metrics only for products with actual activity -- Proceed with calculating daily metrics only for products with actual activity
@@ -121,14 +157,16 @@ BEGIN
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0 HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
), ),
CurrentStock AS ( 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 SELECT
pid, p.pid,
stock_quantity, COALESCE(ss.stock_quantity, p.stock_quantity) AS stock_quantity,
COALESCE(cost_price, 0.00) as effective_cost_price, COALESCE(ss.stock_value, p.stock_quantity * COALESCE(p.cost_price, 0.00)) AS stock_value,
COALESCE(price, 0.00) as current_price, COALESCE(p.price, 0.00) AS current_price,
COALESCE(regular_price, 0.00) as current_regular_price COALESCE(p.regular_price, 0.00) AS current_regular_price
FROM public.products FROM public.products p
LEFT JOIN stock_snapshots ss ON p.pid = ss.pid AND ss.snapshot_date = _target_date
), ),
ProductsWithActivity AS ( ProductsWithActivity AS (
-- Quick pre-filter to only process products with activity -- Quick pre-filter to only process products with activity
@@ -168,7 +206,7 @@ BEGIN
COALESCE(sd.sku, p.sku) AS sku, -- Get SKU from sales data or products table COALESCE(sd.sku, p.sku) AS sku, -- Get SKU from sales data or products table
-- Inventory Metrics (Using CurrentStock) -- Inventory Metrics (Using CurrentStock)
cs.stock_quantity AS eod_stock_quantity, 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_price AS eod_stock_retail,
cs.stock_quantity * cs.current_regular_price AS eod_stock_gross, cs.stock_quantity * cs.current_regular_price AS eod_stock_gross,
(cs.stock_quantity <= 0) AS stockout_flag, (cs.stock_quantity <= 0) AS stockout_flag,
@@ -181,7 +219,7 @@ BEGIN
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 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.cogs, 0.00),
COALESCE(sd.gross_regular_revenue, 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) -- Receiving Metrics (From ReceivingData)
COALESCE(rd.units_received, 0), COALESCE(rd.units_received, 0),
COALESCE(rd.cost_received, 0.00), COALESCE(rd.cost_received, 0.00),

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

@@ -61,16 +61,72 @@ BEGIN
p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each) p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each)
FROM public.products p 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 ( OnOrderInfo AS (
SELECT SELECT
pid, po.pid,
SUM(ordered) AS on_order_qty, SUM(GREATEST(0,
SUM(ordered * po_cost_price) AS on_order_cost, po.ordered - GREATEST(0, LEAST(po.ordered,
MIN(expected_date) AS earliest_expected_date COALESCE(sr.total_received, 0) - po.cumulative_before
FROM public.purchase_orders ))
WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started') )) AS on_order_qty,
AND status NOT IN ('canceled', 'done') SUM(GREATEST(0,
GROUP BY pid 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 ( HistoricalDates AS (
-- Note: Calculating these MIN/MAX values hourly can be slow on large tables. -- Note: Calculating these MIN/MAX values hourly can be slow on large tables.
@@ -142,6 +198,17 @@ BEGIN
FROM public.daily_product_snapshots FROM public.daily_product_snapshots
GROUP BY pid 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 ( FirstPeriodMetrics AS (
SELECT SELECT
pid, pid,
@@ -204,20 +271,33 @@ BEGIN
GROUP BY pid GROUP BY pid
), ),
DemandVariability AS ( 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 SELECT
pid, pid,
COUNT(*) AS days_with_data, COUNT(*) AS days_with_data,
AVG(units_sold) AS avg_daily_sales, SUM(units_sold)::numeric / 30.0 AS avg_daily_sales,
VARIANCE(units_sold) AS sales_variance, CASE WHEN SUM(units_sold) > 0 THEN
STDDEV(units_sold) AS sales_std_dev, (SUM(units_sold::numeric * units_sold::numeric) / 30.0)
-- Coefficient of variation - (SUM(units_sold)::numeric / 30.0) * (SUM(units_sold)::numeric / 30.0)
CASE END AS sales_variance,
WHEN AVG(units_sold) > 0 THEN STDDEV(units_sold) / AVG(units_sold) CASE WHEN SUM(units_sold) > 0 THEN
ELSE NULL (|/ 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 END AS sales_cv
FROM public.daily_product_snapshots FROM public.daily_product_snapshots
WHERE snapshot_date >= _current_date - INTERVAL '29 days' WHERE snapshot_date >= _current_date - INTERVAL '29 days'
AND snapshot_date <= _current_date AND snapshot_date <= _current_date
GROUP BY pid GROUP BY pid
), ),
@@ -242,14 +322,51 @@ BEGIN
GROUP BY pid GROUP BY pid
), ),
SeasonalityAnalysis AS ( SeasonalityAnalysis AS (
-- Simple seasonality detection -- Set-based seasonality detection (replaces per-product function calls)
SELECT -- Computes monthly CV and peak-to-average ratio across the last 12 months
p.pid, SELECT
sp.seasonal_pattern, pid,
sp.seasonality_index, CASE
sp.peak_season WHEN monthly_cv > 0.5 AND seasonality_index > 150 THEN 'strong'
FROM products p WHEN monthly_cv > 0.3 AND seasonality_index > 120 THEN 'moderate'
CROSS JOIN LATERAL detect_seasonal_pattern(p.pid) sp 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 -- Final UPSERT into product_metrics
INSERT INTO public.product_metrics ( INSERT INTO public.product_metrics (
@@ -257,7 +374,7 @@ BEGIN
barcode, harmonized_tariff_code, vendor_reference, notions_reference, line, subline, artist, barcode, harmonized_tariff_code, vendor_reference, notions_reference, line, subline, artist,
moq, rating, reviews, weight, length, width, height, country_of_origin, location, moq, rating, reviews, weight, length, width, height, country_of_origin, location,
baskets, notifies, preorder_count, notions_inv_count, 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, current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date, 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, 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.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.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.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, 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, 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), 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),
@@ -353,10 +470,10 @@ BEGIN
(sa.stockout_days_30d / 30.0) * 100 AS stockout_rate_30d, (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 AS markdown_30d,
((sa.gross_regular_revenue_30d - sa.gross_revenue_30d) / NULLIF(sa.gross_regular_revenue_30d, 0)) * 100 AS markdown_rate_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) -- Sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received)
-- Approximating beginning inventory as current stock + units sold - units received -- Uses actual snapshot from 30 days ago as beginning stock, falls back to avg_stock_units_30d
(sa.sales_30d / NULLIF( (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 0
)) * 100 AS sell_through_30d, )) * 100 AS sell_through_30d,
@@ -505,6 +622,7 @@ BEGIN
LEFT JOIN PreviousPeriodMetrics ppm ON ci.pid = ppm.pid LEFT JOIN PreviousPeriodMetrics ppm ON ci.pid = ppm.pid
LEFT JOIN DemandVariability dv ON ci.pid = dv.pid LEFT JOIN DemandVariability dv ON ci.pid = dv.pid
LEFT JOIN ServiceLevels sl ON ci.pid = sl.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 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 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, 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, 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, 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, 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, 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, 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.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.stock_cover_in_days IS DISTINCT FROM EXCLUDED.stock_cover_in_days OR
product_metrics.yesterday_sales IS DISTINCT FROM EXCLUDED.yesterday_sales 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.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.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_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 -- Update the status table with the timestamp from the START of this run

View File

@@ -171,30 +171,37 @@ router.get('/inventory-summary', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const { rows: [summary] } = await pool.query(` const { rows: [summary] } = await pool.query(`
SELECT WITH agg AS (
SUM(current_stock_cost) AS stock_investment, SELECT
SUM(on_order_cost) AS on_order_value, SUM(current_stock_cost) AS stock_investment,
CASE SUM(on_order_cost) AS on_order_value,
WHEN SUM(avg_stock_cost_30d) > 0 CASE
THEN (SUM(cogs_30d) / SUM(avg_stock_cost_30d)) * 12 WHEN SUM(avg_stock_cost_30d) > 0
ELSE 0 THEN (SUM(cogs_30d) / SUM(avg_stock_cost_30d)) * 12
END AS inventory_turns_annualized, ELSE 0
CASE END AS inventory_turns_annualized,
WHEN SUM(avg_stock_cost_30d) > 0 CASE
THEN SUM(profit_30d) / SUM(avg_stock_cost_30d) WHEN SUM(avg_stock_cost_30d) > 0
ELSE 0 THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12
END AS gmroi, ELSE 0
CASE END AS gmroi,
WHEN SUM(CASE WHEN sales_velocity_daily > 0 THEN 1 ELSE 0 END) > 0 COUNT(*) FILTER (WHERE current_stock > 0) AS products_in_stock,
THEN SUM(CASE WHEN sales_velocity_daily > 0 THEN stock_cover_in_days ELSE 0 END) COUNT(*) FILTER (WHERE is_old_stock = true AND current_stock > 0) AS dead_stock_products,
/ SUM(CASE WHEN sales_velocity_daily > 0 THEN 1 ELSE 0 END) SUM(CASE WHEN is_old_stock = true AND current_stock > 0 THEN current_stock_cost ELSE 0 END) AS dead_stock_value
ELSE 0 FROM product_metrics
END AS avg_stock_cover_days, WHERE is_visible = true
COUNT(*) FILTER (WHERE current_stock > 0) AS products_in_stock, ),
COUNT(*) FILTER (WHERE is_old_stock = true) AS dead_stock_products, cover AS (
SUM(CASE WHEN is_old_stock = true THEN current_stock_cost ELSE 0 END) AS dead_stock_value SELECT
FROM product_metrics PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY stock_cover_in_days) AS median_stock_cover_days
WHERE is_visible = true FROM product_metrics
WHERE is_visible = true
AND current_stock > 0
AND sales_velocity_daily > 0
AND stock_cover_in_days IS NOT NULL
)
SELECT agg.*, cover.median_stock_cover_days
FROM agg, cover
`); `);
res.json({ res.json({
@@ -202,7 +209,7 @@ router.get('/inventory-summary', async (req, res) => {
onOrderValue: Number(summary.on_order_value) || 0, onOrderValue: Number(summary.on_order_value) || 0,
inventoryTurns: Number(summary.inventory_turns_annualized) || 0, inventoryTurns: Number(summary.inventory_turns_annualized) || 0,
gmroi: Number(summary.gmroi) || 0, gmroi: Number(summary.gmroi) || 0,
avgStockCoverDays: Number(summary.avg_stock_cover_days) || 0, avgStockCoverDays: Number(summary.median_stock_cover_days) || 0,
productsInStock: Number(summary.products_in_stock) || 0, productsInStock: Number(summary.products_in_stock) || 0,
deadStockProducts: Number(summary.dead_stock_products) || 0, deadStockProducts: Number(summary.dead_stock_products) || 0,
deadStockValue: Number(summary.dead_stock_value) || 0, deadStockValue: Number(summary.dead_stock_value) || 0,
@@ -266,9 +273,9 @@ router.get('/portfolio', async (req, res) => {
// Dead stock and overstock summary // Dead stock and overstock summary
const { rows: [stockIssues] } = await pool.query(` const { rows: [stockIssues] } = await pool.query(`
SELECT SELECT
COUNT(*) FILTER (WHERE is_old_stock = true) AS dead_stock_count, COUNT(*) FILTER (WHERE is_old_stock = true AND current_stock > 0) AS dead_stock_count,
SUM(CASE WHEN is_old_stock = true THEN current_stock_cost ELSE 0 END) AS dead_stock_cost, SUM(CASE WHEN is_old_stock = true AND current_stock > 0 THEN current_stock_cost ELSE 0 END) AS dead_stock_cost,
SUM(CASE WHEN is_old_stock = true THEN current_stock_retail ELSE 0 END) AS dead_stock_retail, SUM(CASE WHEN is_old_stock = true AND current_stock > 0 THEN current_stock_retail ELSE 0 END) AS dead_stock_retail,
COUNT(*) FILTER (WHERE overstocked_units > 0) AS overstock_count, COUNT(*) FILTER (WHERE overstocked_units > 0) AS overstock_count,
SUM(COALESCE(overstocked_cost, 0)) AS overstock_cost, SUM(COALESCE(overstocked_cost, 0)) AS overstock_cost,
SUM(COALESCE(overstocked_retail, 0)) AS overstock_retail SUM(COALESCE(overstocked_retail, 0)) AS overstock_retail
@@ -300,36 +307,36 @@ router.get('/portfolio', async (req, res) => {
} }
}); });
// Capital efficiency — GMROI by vendor (single combined query) // Capital efficiency — GMROI by brand (single combined query)
router.get('/efficiency', async (req, res) => { router.get('/efficiency', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT SELECT
vendor AS vendor_name, COALESCE(brand, 'Unbranded') AS brand_name,
COUNT(*) AS product_count, COUNT(*) AS product_count,
SUM(current_stock_cost) AS stock_cost, SUM(current_stock_cost) AS stock_cost,
SUM(profit_30d) AS profit_30d, SUM(profit_30d) AS profit_30d,
SUM(revenue_30d) AS revenue_30d, SUM(revenue_30d) AS revenue_30d,
CASE CASE
WHEN SUM(avg_stock_cost_30d) > 0 WHEN SUM(avg_stock_cost_30d) > 0
THEN SUM(profit_30d) / SUM(avg_stock_cost_30d) THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12
ELSE 0 ELSE 0
END AS gmroi END AS gmroi
FROM product_metrics FROM product_metrics
WHERE is_visible = true WHERE is_visible = true
AND vendor IS NOT NULL AND brand IS NOT NULL
AND current_stock_cost > 0 AND current_stock_cost > 0
GROUP BY vendor GROUP BY brand
HAVING SUM(current_stock_cost) > 100 HAVING SUM(current_stock_cost) > 100
ORDER BY SUM(current_stock_cost) DESC ORDER BY SUM(current_stock_cost) DESC
LIMIT 30 LIMIT 30
`); `);
res.json({ res.json({
vendors: rows.map(r => ({ brands: rows.map(r => ({
vendor: r.vendor_name, brand: r.brand_name,
productCount: Number(r.product_count) || 0, productCount: Number(r.product_count) || 0,
stockCost: Number(r.stock_cost) || 0, stockCost: Number(r.stock_cost) || 0,
profit30d: Number(r.profit_30d) || 0, profit30d: Number(r.profit_30d) || 0,
@@ -527,7 +534,7 @@ router.get('/stockout-risk', async (req, res) => {
const { rows } = await pool.query(` const { rows } = await pool.query(`
WITH base AS ( WITH base AS (
SELECT SELECT
title, sku, vendor, title, sku, brand,
${leadTimeSql} AS lead_time_days, ${leadTimeSql} AS lead_time_days,
sells_out_in_days, current_stock, sales_velocity_daily, sells_out_in_days, current_stock, sales_velocity_daily,
revenue_30d, abc_class revenue_30d, abc_class
@@ -554,7 +561,7 @@ router.get('/stockout-risk', async (req, res) => {
products: rows.map(r => ({ products: rows.map(r => ({
title: r.title, title: r.title,
sku: r.sku, sku: r.sku,
vendor: r.vendor, brand: r.brand,
leadTimeDays: Number(r.lead_time_days) || 0, leadTimeDays: Number(r.lead_time_days) || 0,
sellsOutInDays: Number(r.sells_out_in_days) || 0, sellsOutInDays: Number(r.sells_out_in_days) || 0,
currentStock: Number(r.current_stock) || 0, currentStock: Number(r.current_stock) || 0,
@@ -624,6 +631,7 @@ router.get('/growth', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
// ABC breakdown — only "comparable" products (sold in BOTH periods, i.e. growth != -100%)
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT SELECT
COALESCE(abc_class, 'N/A') AS abc_class, COALESCE(abc_class, 'N/A') AS abc_class,
@@ -645,21 +653,39 @@ router.get('/growth', async (req, res) => {
FROM product_metrics FROM product_metrics
WHERE is_visible = true WHERE is_visible = true
AND sales_growth_yoy IS NOT NULL AND sales_growth_yoy IS NOT NULL
AND sales_30d > 0
GROUP BY 1, 2, 3 GROUP BY 1, 2, 3
ORDER BY abc_class, sort_order ORDER BY abc_class, sort_order
`); `);
// Summary stats // Summary: comparable products (sold in both periods) with revenue-weighted avg
const { rows: [summary] } = await pool.query(` const { rows: [summary] } = await pool.query(`
SELECT SELECT
COUNT(*) AS total_with_yoy, COUNT(*) AS comparable_count,
COUNT(*) FILTER (WHERE sales_growth_yoy > 0) AS growing_count, COUNT(*) FILTER (WHERE sales_growth_yoy > 0) AS growing_count,
COUNT(*) FILTER (WHERE sales_growth_yoy <= 0) AS declining_count, COUNT(*) FILTER (WHERE sales_growth_yoy <= 0) AS declining_count,
ROUND(AVG(sales_growth_yoy)::numeric, 1) AS avg_growth, ROUND(
CASE WHEN SUM(revenue_30d) > 0
THEN SUM(sales_growth_yoy * revenue_30d) / SUM(revenue_30d)
ELSE 0
END::numeric, 1
) AS weighted_avg_growth,
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sales_growth_yoy)::numeric, 1) AS median_growth ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sales_growth_yoy)::numeric, 1) AS median_growth
FROM product_metrics FROM product_metrics
WHERE is_visible = true WHERE is_visible = true
AND sales_growth_yoy IS NOT NULL AND sales_growth_yoy IS NOT NULL
AND sales_30d > 0
`);
// Catalog turnover: new products (selling now, no sales last year) and discontinued (sold last year, not now)
const { rows: [turnover] } = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE sales_growth_yoy IS NULL AND sales_30d > 0 AND age_days < 365) AS new_products,
SUM(revenue_30d) FILTER (WHERE sales_growth_yoy IS NULL AND sales_30d > 0 AND age_days < 365) AS new_product_revenue,
COUNT(*) FILTER (WHERE sales_growth_yoy = -100) AS discontinued,
SUM(current_stock_cost) FILTER (WHERE sales_growth_yoy = -100 AND current_stock > 0) AS discontinued_stock_value
FROM product_metrics
WHERE is_visible = true
`); `);
res.json({ res.json({
@@ -671,12 +697,18 @@ router.get('/growth', async (req, res) => {
stockCost: Number(r.stock_cost) || 0, stockCost: Number(r.stock_cost) || 0,
})), })),
summary: { summary: {
totalWithYoy: Number(summary.total_with_yoy) || 0, comparableCount: Number(summary.comparable_count) || 0,
growingCount: Number(summary.growing_count) || 0, growingCount: Number(summary.growing_count) || 0,
decliningCount: Number(summary.declining_count) || 0, decliningCount: Number(summary.declining_count) || 0,
avgGrowth: Number(summary.avg_growth) || 0, weightedAvgGrowth: Number(summary.weighted_avg_growth) || 0,
medianGrowth: Number(summary.median_growth) || 0, medianGrowth: Number(summary.median_growth) || 0,
}, },
turnover: {
newProducts: Number(turnover.new_products) || 0,
newProductRevenue: Number(turnover.new_product_revenue) || 0,
discontinued: Number(turnover.discontinued) || 0,
discontinuedStockValue: Number(turnover.discontinued_stock_value) || 0,
},
}); });
} catch (error) { } catch (error) {
console.error('Error fetching growth data:', error); console.error('Error fetching growth data:', error);
@@ -684,4 +716,126 @@ router.get('/growth', async (req, res) => {
} }
}); });
// Inventory value over time (uses stock_snapshots — full product coverage)
router.get('/inventory-value', async (req, res) => {
try {
const pool = req.app.locals.pool;
const period = parseInt(req.query.period) || 90;
const validPeriods = [30, 90, 365];
const days = validPeriods.includes(period) ? period : 90;
const { rows } = await pool.query(`
SELECT
snapshot_date AS date,
ROUND(SUM(stock_value)::numeric, 0) AS total_value,
COUNT(DISTINCT pid) AS product_count
FROM stock_snapshots
WHERE snapshot_date >= CURRENT_DATE - make_interval(days => $1)
GROUP BY snapshot_date
ORDER BY snapshot_date
`, [days]);
res.json(rows.map(r => ({
date: r.date,
totalValue: Number(r.total_value) || 0,
productCount: Number(r.product_count) || 0,
})));
} catch (error) {
console.error('Error fetching inventory value:', error);
res.status(500).json({ error: 'Failed to fetch inventory value' });
}
});
// Inventory flow: receiving vs selling per day
router.get('/flow', async (req, res) => {
try {
const pool = req.app.locals.pool;
const period = parseInt(req.query.period) || 30;
const validPeriods = [30, 90];
const days = validPeriods.includes(period) ? period : 30;
const { rows } = await pool.query(`
SELECT
snapshot_date AS date,
COALESCE(SUM(units_received), 0) AS units_received,
ROUND(COALESCE(SUM(cost_received), 0)::numeric, 0) AS cost_received,
COALESCE(SUM(units_sold), 0) AS units_sold,
ROUND(COALESCE(SUM(cogs), 0)::numeric, 0) AS cogs_sold
FROM daily_product_snapshots
WHERE snapshot_date >= CURRENT_DATE - make_interval(days => $1)
GROUP BY snapshot_date
ORDER BY snapshot_date
`, [days]);
res.json(rows.map(r => ({
date: r.date,
unitsReceived: Number(r.units_received) || 0,
costReceived: Number(r.cost_received) || 0,
unitsSold: Number(r.units_sold) || 0,
cogsSold: Number(r.cogs_sold) || 0,
})));
} catch (error) {
console.error('Error fetching inventory flow:', error);
res.status(500).json({ error: 'Failed to fetch inventory flow' });
}
});
// Seasonal pattern distribution
router.get('/seasonal', async (req, res) => {
try {
const pool = req.app.locals.pool;
const { rows: patterns } = await pool.query(`
SELECT
COALESCE(seasonal_pattern, 'unknown') AS pattern,
COUNT(*) AS product_count,
SUM(current_stock_cost) AS stock_cost,
SUM(revenue_30d) AS revenue
FROM product_metrics
WHERE is_visible = true
AND current_stock > 0
GROUP BY seasonal_pattern
ORDER BY COUNT(*) DESC
`);
const { rows: peakSeasons } = await pool.query(`
SELECT
peak_season AS month,
COUNT(*) AS product_count,
SUM(current_stock_cost) AS stock_cost
FROM product_metrics
WHERE is_visible = true
AND current_stock > 0
AND seasonal_pattern IN ('moderate', 'strong')
AND peak_season IS NOT NULL
GROUP BY peak_season
ORDER BY
CASE peak_season
WHEN 'January' THEN 1 WHEN 'February' THEN 2 WHEN 'March' THEN 3
WHEN 'April' THEN 4 WHEN 'May' THEN 5 WHEN 'June' THEN 6
WHEN 'July' THEN 7 WHEN 'August' THEN 8 WHEN 'September' THEN 9
WHEN 'October' THEN 10 WHEN 'November' THEN 11 WHEN 'December' THEN 12
ELSE 13
END
`);
res.json({
patterns: patterns.map(r => ({
pattern: r.pattern,
productCount: Number(r.product_count) || 0,
stockCost: Number(r.stock_cost) || 0,
revenue: Number(r.revenue) || 0,
})),
peakSeasons: peakSeasons.map(r => ({
month: r.month,
productCount: Number(r.product_count) || 0,
stockCost: Number(r.stock_cost) || 0,
})),
});
} catch (error) {
console.error('Error fetching seasonal data:', error);
res.status(500).json({ error: 'Failed to fetch seasonal data' });
}
});
module.exports = router; module.exports = router;

File diff suppressed because it is too large Load Diff

View File

@@ -1144,10 +1144,11 @@ router.get('/search-products', async (req, res) => {
p.harmonized_tariff_code, p.harmonized_tariff_code,
pcp.price_each AS price, pcp.price_each AS price,
p.sellingprice AS regular_price, p.sellingprice AS regular_price,
CASE CASE
WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) WHEN sid.supplier_id = 92 THEN
THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0) CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) 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, END AS cost_price,
s.companyname AS vendor, s.companyname AS vendor,
sid.supplier_itemnumber AS vendor_reference, sid.supplier_itemnumber AS vendor_reference,
@@ -1266,9 +1267,10 @@ const PRODUCT_SELECT = `
pcp.price_each AS price, pcp.price_each AS price,
p.sellingprice AS regular_price, p.sellingprice AS regular_price,
CASE CASE
WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) WHEN sid.supplier_id = 92 THEN
THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0) CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) 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, END AS cost_price,
s.companyname AS vendor, s.companyname AS vendor,
sid.supplier_itemnumber AS vendor_reference, sid.supplier_itemnumber AS vendor_reference,

View File

@@ -782,4 +782,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; module.exports = router;

View File

@@ -1185,4 +1185,96 @@ router.get('/delivery-metrics', async (req, res) => {
} }
}); });
module.exports = router; // 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

@@ -0,0 +1,234 @@
/**
* 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 } 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;
productName?: string;
className?: string;
}
export function AiDescriptionCompare({
currentValue,
onCurrentChange,
suggestion,
issues,
onAccept,
onDismiss,
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>
</div>
</div>
</div>
</div>
);
}

View File

@@ -31,7 +31,7 @@ function getSellThroughColor(rate: number): string {
} }
export function AgingSellThrough() { export function AgingSellThrough() {
const { data, isLoading } = useQuery<AgingCohort[]>({ const { data, isLoading, isError } = useQuery<AgingCohort[]>({
queryKey: ['aging-sell-through'], queryKey: ['aging-sell-through'],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/aging`); const response = await fetch(`${config.apiUrl}/analytics/aging`);
@@ -40,6 +40,19 @@ export function AgingSellThrough() {
}, },
}); });
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) { if (isLoading || !data) {
return ( return (
<Card> <Card>

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
@@ -18,8 +19,8 @@ import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency'; import { formatCurrency } from '@/utils/formatCurrency';
interface VendorData { interface BrandData {
vendor: string; brand: string;
productCount: number; productCount: number;
stockCost: number; stockCost: number;
profit30d: number; profit30d: number;
@@ -28,17 +29,20 @@ interface VendorData {
} }
interface EfficiencyData { interface EfficiencyData {
vendors: VendorData[]; brands: BrandData[];
} }
function getGmroiColor(gmroi: number): string { function getGmroiColor(gmroi: number): string {
if (gmroi >= 1) return METRIC_COLORS.revenue; // emerald — good if (gmroi >= 3) return METRIC_COLORS.revenue; // emerald — strong
if (gmroi >= 0.3) return METRIC_COLORS.comparison; // amber — ok if (gmroi >= 1) return METRIC_COLORS.comparison; // amber — acceptable
return '#ef4444'; // red — poor return '#ef4444'; // red — poor
} }
type GmroiView = 'top' | 'bottom';
export function CapitalEfficiency() { export function CapitalEfficiency() {
const { data, isLoading } = useQuery<EfficiencyData>({ const [gmroiView, setGmroiView] = useState<GmroiView>('top');
const { data, isLoading, isError } = useQuery<EfficiencyData>({
queryKey: ['capital-efficiency'], queryKey: ['capital-efficiency'],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/efficiency`); const response = await fetch(`${config.apiUrl}/analytics/efficiency`);
@@ -47,6 +51,19 @@ export function CapitalEfficiency() {
}, },
}); });
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) { if (isLoading || !data) {
return ( return (
<Card> <Card>
@@ -60,17 +77,38 @@ export function CapitalEfficiency() {
); );
} }
// Top 15 by GMROI for bar chart // Top or bottom 15 by GMROI for bar chart
const sortedGmroi = [...data.vendors].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15); 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 ( return (
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>GMROI by Vendor</CardTitle> <div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground"> <div>
Gross margin return on inventory investment (top vendors by stock value) <CardTitle>GMROI by Brand</CardTitle>
</p> <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> </CardHeader>
<CardContent> <CardContent>
<ResponsiveContainer width="100%" height={400}> <ResponsiveContainer width="100%" height={400}>
@@ -79,17 +117,17 @@ export function CapitalEfficiency() {
<XAxis type="number" tick={{ fontSize: 11 }} /> <XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis <YAxis
type="category" type="category"
dataKey="vendor" dataKey="brand"
width={140} width={140}
tick={{ fontSize: 11 }} tick={{ fontSize: 11 }}
/> />
<Tooltip <Tooltip
content={({ active, payload }) => { content={({ active, payload }) => {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
const d = payload[0].payload as VendorData; const d = payload[0].payload as BrandData;
return ( return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm"> <div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.vendor}</p> <p className="font-medium mb-1">{d.brand}</p>
<p>GMROI: <span className="font-medium">{d.gmroi.toFixed(2)}</span></p> <p>GMROI: <span className="font-medium">{d.gmroi.toFixed(2)}</span></p>
<p>Stock Investment: {formatCurrency(d.stockCost)}</p> <p>Stock Investment: {formatCurrency(d.stockCost)}</p>
<p>Profit (30d): {formatCurrency(d.profit30d)}</p> <p>Profit (30d): {formatCurrency(d.profit30d)}</p>
@@ -99,7 +137,7 @@ export function CapitalEfficiency() {
); );
}} }}
/> />
<ReferenceLine x={1} stroke="#9ca3af" strokeDasharray="3 3" label={{ value: '1.0', position: 'top', fontSize: 10 }} /> <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]}> <Bar dataKey="gmroi" name="GMROI" radius={[0, 4, 4, 0]}>
{sortedGmroi.map((entry, i) => ( {sortedGmroi.map((entry, i) => (
<Cell key={i} fill={getGmroiColor(entry.gmroi)} /> <Cell key={i} fill={getGmroiColor(entry.gmroi)} />
@@ -112,7 +150,7 @@ export function CapitalEfficiency() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Investment vs Profit by Vendor</CardTitle> <CardTitle>Investment vs Profit by Brand</CardTitle>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Bubble size = product count. Ideal: high profit, low stock cost. Bubble size = product count. Ideal: high profit, low stock cost.
</p> </p>
@@ -141,10 +179,10 @@ export function CapitalEfficiency() {
<Tooltip <Tooltip
content={({ active, payload }) => { content={({ active, payload }) => {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
const d = payload[0].payload as VendorData; const d = payload[0].payload as BrandData;
return ( return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm"> <div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.vendor}</p> <p className="font-medium mb-1">{d.brand}</p>
<p>Stock Investment: {formatCurrency(d.stockCost)}</p> <p>Stock Investment: {formatCurrency(d.stockCost)}</p>
<p>Profit (30d): {formatCurrency(d.profit30d)}</p> <p>Profit (30d): {formatCurrency(d.profit30d)}</p>
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p> <p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
@@ -153,7 +191,7 @@ export function CapitalEfficiency() {
); );
}} }}
/> />
<Scatter data={data.vendors} fill={METRIC_COLORS.orders} fillOpacity={0.6} /> <Scatter data={data.brands} fill={METRIC_COLORS.orders} fillOpacity={0.6} />
</ScatterChart> </ScatterChart>
</ResponsiveContainer> </ResponsiveContainer>
</CardContent> </CardContent>

View File

@@ -31,7 +31,7 @@ const CLASS_COLORS: Record<string, string> = {
}; };
export function DiscountImpact() { export function DiscountImpact() {
const { data, isLoading } = useQuery<DiscountRow[]>({ const { data, isLoading, isError } = useQuery<DiscountRow[]>({
queryKey: ['discount-impact'], queryKey: ['discount-impact'],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/discounts`); const response = await fetch(`${config.apiUrl}/analytics/discounts`);
@@ -40,6 +40,19 @@ export function DiscountImpact() {
}, },
}); });
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) { if (isLoading || !data) {
return ( return (
<Card> <Card>

View File

@@ -12,7 +12,8 @@ import {
} from 'recharts'; } from 'recharts';
import config from '../../config'; import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { TrendingUp, TrendingDown } from 'lucide-react'; import { TrendingUp, TrendingDown, Plus, Archive } from 'lucide-react';
import { formatCurrency } from '@/utils/formatCurrency';
interface GrowthRow { interface GrowthRow {
abcClass: string; abcClass: string;
@@ -23,16 +24,24 @@ interface GrowthRow {
} }
interface GrowthSummary { interface GrowthSummary {
totalWithYoy: number; comparableCount: number;
growingCount: number; growingCount: number;
decliningCount: number; decliningCount: number;
avgGrowth: number; weightedAvgGrowth: number;
medianGrowth: number; medianGrowth: number;
} }
interface CatalogTurnover {
newProducts: number;
newProductRevenue: number;
discontinued: number;
discontinuedStockValue: number;
}
interface GrowthData { interface GrowthData {
byClass: GrowthRow[]; byClass: GrowthRow[];
summary: GrowthSummary; summary: GrowthSummary;
turnover: CatalogTurnover;
} }
const GROWTH_COLORS: Record<string, string> = { const GROWTH_COLORS: Record<string, string> = {
@@ -43,7 +52,7 @@ const GROWTH_COLORS: Record<string, string> = {
}; };
export function GrowthMomentum() { export function GrowthMomentum() {
const { data, isLoading } = useQuery<GrowthData>({ const { data, isLoading, isError } = useQuery<GrowthData>({
queryKey: ['growth-momentum'], queryKey: ['growth-momentum'],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/growth`); const response = await fetch(`${config.apiUrl}/analytics/growth`);
@@ -52,6 +61,19 @@ export function GrowthMomentum() {
}, },
}); });
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) { if (isLoading || !data) {
return ( return (
<Card> <Card>
@@ -65,9 +87,9 @@ export function GrowthMomentum() {
); );
} }
const { summary } = data; const { summary, turnover } = data;
const growthPct = summary.totalWithYoy > 0 const growingPct = summary.comparableCount > 0
? ((summary.growingCount / summary.totalWithYoy) * 100).toFixed(0) ? ((summary.growingCount / summary.comparableCount) * 100).toFixed(0)
: '0'; : '0';
// Pivot: for each ABC class, show product counts by growth bucket // Pivot: for each ABC class, show product counts by growth bucket
@@ -84,6 +106,7 @@ export function GrowthMomentum() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Row 1: Comparable growth metrics */}
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
<Card> <Card>
<CardContent className="flex items-center gap-3 py-4"> <CardContent className="flex items-center gap-3 py-4">
@@ -91,9 +114,9 @@ export function GrowthMomentum() {
<TrendingUp className="h-4 w-4 text-green-500" /> <TrendingUp className="h-4 w-4 text-green-500" />
</div> </div>
<div> <div>
<p className="text-sm font-medium">Growing</p> <p className="text-sm font-medium">Comparable Growing</p>
<p className="text-xl font-bold">{growthPct}%</p> <p className="text-xl font-bold">{growingPct}%</p>
<p className="text-xs text-muted-foreground">{summary.growingCount.toLocaleString()} products</p> <p className="text-xs text-muted-foreground">{summary.growingCount.toLocaleString()} of {summary.comparableCount.toLocaleString()} products</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -103,18 +126,19 @@ export function GrowthMomentum() {
<TrendingDown className="h-4 w-4 text-red-500" /> <TrendingDown className="h-4 w-4 text-red-500" />
</div> </div>
<div> <div>
<p className="text-sm font-medium">Declining</p> <p className="text-sm font-medium">Comparable Declining</p>
<p className="text-xl font-bold">{summary.decliningCount.toLocaleString()}</p> <p className="text-xl font-bold">{summary.decliningCount.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">products</p> <p className="text-xs text-muted-foreground">products with lower YoY sales</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="py-4"> <CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">Avg YoY Growth</p> <p className="text-sm font-medium text-muted-foreground">Weighted Avg Growth</p>
<p className={`text-2xl font-bold ${summary.avgGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}> <p className={`text-2xl font-bold ${summary.weightedAvgGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{summary.avgGrowth > 0 ? '+' : ''}{summary.avgGrowth}% {summary.weightedAvgGrowth > 0 ? '+' : ''}{summary.weightedAvgGrowth}%
</p> </p>
<p className="text-xs text-muted-foreground">revenue-weighted</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -123,16 +147,49 @@ export function GrowthMomentum() {
<p className={`text-2xl font-bold ${summary.medianGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}> <p className={`text-2xl font-bold ${summary.medianGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{summary.medianGrowth > 0 ? '+' : ''}{summary.medianGrowth}% {summary.medianGrowth > 0 ? '+' : ''}{summary.medianGrowth}%
</p> </p>
<p className="text-xs text-muted-foreground">{summary.totalWithYoy.toLocaleString()} products tracked</p> <p className="text-xs text-muted-foreground">typical product growth</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </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> <Card>
<CardHeader> <CardHeader>
<CardTitle>Growth Distribution by ABC Class</CardTitle> <CardTitle>Comparable Growth by ABC Class</CardTitle>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Year-over-year sales growth segmented by product importance Products selling in both this and last year's period — excludes new launches and discontinued
</p> </p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

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

@@ -32,7 +32,7 @@ function formatDate(dateStr: string, period: Period): string {
export function InventoryTrends() { export function InventoryTrends() {
const [period, setPeriod] = useState<Period>(90); const [period, setPeriod] = useState<Period>(90);
const { data, isLoading } = useQuery<TrendPoint[]>({ const { data, isLoading, isError } = useQuery<TrendPoint[]>({
queryKey: ['inventory-trends', period], queryKey: ['inventory-trends', period],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/inventory-trends?period=${period}`); const response = await fetch(`${config.apiUrl}/analytics/inventory-trends?period=${period}`);
@@ -69,7 +69,11 @@ export function InventoryTrends() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading || !data ? ( {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="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading trends...</div> <div className="animate-pulse text-muted-foreground">Loading trends...</div>
</div> </div>

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

@@ -39,7 +39,7 @@ interface PortfolioData {
} }
export function PortfolioAnalysis() { export function PortfolioAnalysis() {
const { data, isLoading } = useQuery<PortfolioData>({ const { data, isLoading, isError } = useQuery<PortfolioData>({
queryKey: ['portfolio-analysis'], queryKey: ['portfolio-analysis'],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/portfolio`); const response = await fetch(`${config.apiUrl}/analytics/portfolio`);
@@ -48,6 +48,19 @@ export function PortfolioAnalysis() {
}, },
}); });
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) { if (isLoading || !data) {
return ( return (
<Card> <Card>

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

@@ -69,7 +69,7 @@ function getCoverColor(bucket: string): string {
} }
export function StockHealth() { export function StockHealth() {
const { data, isLoading } = useQuery<StockHealthData>({ const { data, isLoading, isError } = useQuery<StockHealthData>({
queryKey: ['stock-health'], queryKey: ['stock-health'],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/stock-health`); const response = await fetch(`${config.apiUrl}/analytics/stock-health`);
@@ -78,6 +78,19 @@ export function StockHealth() {
}, },
}); });
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) { if (isLoading || !data) {
return ( return (
<Card> <Card>

View File

@@ -18,7 +18,7 @@ import { formatCurrency } from '@/utils/formatCurrency';
interface RiskProduct { interface RiskProduct {
title: string; title: string;
sku: string; sku: string;
vendor: string; brand: string;
leadTimeDays: number; leadTimeDays: number;
sellsOutInDays: number; sellsOutInDays: number;
currentStock: number; currentStock: number;
@@ -46,7 +46,7 @@ function getRiskColor(product: RiskProduct): string {
} }
export function StockoutRisk() { export function StockoutRisk() {
const { data, isLoading } = useQuery<StockoutRiskData>({ const { data, isLoading, isError } = useQuery<StockoutRiskData>({
queryKey: ['stockout-risk'], queryKey: ['stockout-risk'],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/stockout-risk`); const response = await fetch(`${config.apiUrl}/analytics/stockout-risk`);
@@ -55,6 +55,19 @@ export function StockoutRisk() {
}, },
}); });
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) { if (isLoading || !data) {
return ( return (
<Card> <Card>
@@ -127,7 +140,7 @@ export function StockoutRisk() {
{/* Diagonal risk line (y = x): products below this stock out before replenishment */} {/* Diagonal risk line (y = x): products below this stock out before replenishment */}
<Scatter <Scatter
data={(() => { data={(() => {
const max = Math.max(...products.map(d => Math.max(d.leadTimeDays, d.sellsOutInDays))); const max = products.length > 0 ? Math.max(...products.map(d => Math.max(d.leadTimeDays, d.sellsOutInDays))) : 100;
return [ return [
{ leadTimeDays: 0, sellsOutInDays: 0, revenue30d: 0 }, { leadTimeDays: 0, sellsOutInDays: 0, revenue30d: 0 },
{ leadTimeDays: max, sellsOutInDays: max, revenue30d: 0 }, { leadTimeDays: max, sellsOutInDays: max, revenue30d: 0 },

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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
interface Product { interface Product {
pid: number; pid: number;
@@ -22,7 +22,6 @@ interface Category {
units_sold: number; units_sold: number;
revenue: string; revenue: string;
profit: string; profit: string;
growth_rate: string;
} }
interface BestSellerBrand { interface BestSellerBrand {
@@ -39,14 +38,22 @@ interface BestSellersData {
categories: Category[] 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() { export function BestSellers() {
const { data } = useQuery<BestSellersData>({ const { data, isError, isLoading } = useQuery<BestSellersData>({
queryKey: ["best-sellers"], queryKey: ["best-sellers"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/best-sellers`) const response = await fetch(`${config.apiUrl}/dashboard/best-sellers`)
if (!response.ok) { if (!response.ok) throw new Error("Failed to fetch best sellers");
throw new Error("Failed to fetch best sellers")
}
return response.json() return response.json()
}, },
}) })
@@ -65,111 +72,121 @@ export function BestSellers() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<TabsContent value="products"> {isError ? (
<ScrollArea className="h-[385px] w-full"> <p className="text-sm text-destructive">Failed to load best sellers</p>
<Table> ) : isLoading ? (
<TableHeader> <TableSkeleton />
<TableRow> ) : (
<TableHead>Product</TableHead> <>
<TableHead className="text-right">Units Sold</TableHead> <TabsContent value="products">
<TableHead className="text-right">Revenue</TableHead> <ScrollArea className="h-[420px] w-full">
<TableHead className="text-right">Profit</TableHead> <Table>
</TableRow> <TableHeader>
</TableHeader> <TableRow>
<TableBody> <TableHead>Product</TableHead>
{data?.products.map((product) => ( <TableHead className="text-right">Units Sold</TableHead>
<TableRow key={product.pid}> <TableHead className="text-right">Revenue</TableHead>
<TableCell> <TableHead className="text-right">Profit</TableHead>
<a </TableRow>
href={`https://backend.acherryontop.com/product/${product.pid}`} </TableHeader>
target="_blank" <TableBody>
rel="noopener noreferrer" {data?.products.map((product) => (
className="hover:underline" <TableRow key={product.pid}>
> <TableCell>
{product.title} <a
</a> href={`https://backend.acherryontop.com/product/${product.pid}`}
<div className="text-sm text-muted-foreground">{product.sku}</div> target="_blank"
</TableCell> rel="noopener noreferrer"
<TableCell className="text-right">{product.units_sold}</TableCell> className="hover:underline"
<TableCell className="text-right">{formatCurrency(Number(product.revenue))}</TableCell> >
<TableCell className="text-right">{formatCurrency(Number(product.profit))}</TableCell> {product.title}
</TableRow> </a>
))} <div className="text-sm text-muted-foreground">{product.sku}</div>
</TableBody> </TableCell>
</Table> <TableCell className="text-right">{product.units_sold}</TableCell>
</ScrollArea> <TableCell className="text-right">{formatCurrency(Number(product.revenue))}</TableCell>
</TabsContent> <TableCell className="text-right">{formatCurrency(Number(product.profit))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</TabsContent>
<TabsContent value="brands"> <TabsContent value="brands">
<ScrollArea className="h-[400px] w-full"> <ScrollArea className="h-[400px] w-full">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[40%]">Brand</TableHead> <TableHead className="w-[40%]">Brand</TableHead>
<TableHead className="w-[15%] text-right">Sales</TableHead> <TableHead className="w-[15%] text-right">Sales</TableHead>
<TableHead className="w-[15%] text-right">Revenue</TableHead> <TableHead className="w-[15%] text-right">Revenue</TableHead>
<TableHead className="w-[15%] text-right">Profit</TableHead> <TableHead className="w-[15%] text-right">Profit</TableHead>
<TableHead className="w-[15%] text-right">Growth</TableHead> <TableHead className="w-[15%] text-right">Growth</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.brands.map((brand) => ( {data?.brands.map((brand) => (
<TableRow key={brand.brand}> <TableRow key={brand.brand}>
<TableCell className="w-[40%]"> <TableCell className="w-[40%]">
<p className="font-medium">{brand.brand}</p> <p className="font-medium">{brand.brand}</p>
</TableCell> </TableCell>
<TableCell className="w-[15%] text-right"> <TableCell className="w-[15%] text-right">
{brand.units_sold.toLocaleString()} {brand.units_sold.toLocaleString()}
</TableCell> </TableCell>
<TableCell className="w-[15%] text-right"> <TableCell className="w-[15%] text-right">
{formatCurrency(Number(brand.revenue))} {formatCurrency(Number(brand.revenue))}
</TableCell> </TableCell>
<TableCell className="w-[15%] text-right"> <TableCell className="w-[15%] text-right">
{formatCurrency(Number(brand.profit))} {formatCurrency(Number(brand.profit))}
</TableCell> </TableCell>
<TableCell className="w-[15%] text-right"> <TableCell className="w-[15%] text-right">
{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}% {brand.growth_rate != null ? (
</TableCell> <>{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}%</>
</TableRow> ) : '-'}
))} </TableCell>
</TableBody> </TableRow>
</Table> ))}
</ScrollArea> </TableBody>
</TabsContent> </Table>
</ScrollArea>
</TabsContent>
<TabsContent value="categories"> <TabsContent value="categories">
<ScrollArea className="h-[400px] w-full"> <ScrollArea className="h-[400px] w-full">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Category</TableHead> <TableHead>Category</TableHead>
<TableHead className="text-right">Units Sold</TableHead> <TableHead className="text-right">Units Sold</TableHead>
<TableHead className="text-right">Revenue</TableHead> <TableHead className="text-right">Revenue</TableHead>
<TableHead className="text-right">Profit</TableHead> <TableHead className="text-right">Profit</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.categories.map((category) => ( {data?.categories.map((category) => (
<TableRow key={category.cat_id}> <TableRow key={category.cat_id}>
<TableCell> <TableCell>
<div className="font-medium">{category.name}</div> <div className="font-medium">{category.name}</div>
{category.categoryPath && ( {category.categoryPath && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{category.categoryPath} {category.categoryPath}
</div> </div>
)} )}
</TableCell> </TableCell>
<TableCell className="text-right">{category.units_sold}</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.revenue))}</TableCell>
<TableCell className="text-right">{formatCurrency(Number(category.profit))}</TableCell> <TableCell className="text-right">{formatCurrency(Number(category.profit))}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
</>
)}
</CardContent> </CardContent>
</Tabs> </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 { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" 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 { useState } from "react"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
import { TrendingUp, DollarSign } from "lucide-react" import { TrendingUp, DollarSign, Target } from "lucide-react"
import { DateRange } from "react-day-picker" 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 { 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 { interface ForecastData {
forecastSales: number forecastSales: number
forecastRevenue: string forecastRevenue: number
confidenceLevel: number confidenceLevel: number
dailyForecasts: { dailyForecasts: {
date: string date: string
@@ -19,6 +52,8 @@ interface ForecastData {
revenue: string revenue: string
confidence: number confidence: number
}[] }[]
dailyForecastsByPhase?: DailyPhaseData[]
phaseBreakdown?: PhaseData[]
categoryForecasts: { categoryForecasts: {
category: string category: string
units: number units: number
@@ -28,17 +63,14 @@ interface ForecastData {
} }
export function ForecastMetrics() { export function ForecastMetrics() {
const [dateRange, setDateRange] = useState<DateRange>({ const [period, setPeriod] = useState<Period>(30);
from: new Date(),
to: addDays(new Date(), 30),
});
const { data, error, isLoading } = useQuery<ForecastData>({ const { data, error, isLoading } = useQuery<ForecastData>({
queryKey: ["forecast-metrics", dateRange], queryKey: ["forecast-metrics", period],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({
startDate: dateRange.from?.toISOString() || "", startDate: new Date().toISOString(),
endDate: dateRange.to?.toISOString() || "", endDate: getEndDate(period).toISOString(),
}); });
const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?${params}`) const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?${params}`)
if (!response.ok) { if (!response.ok) {
@@ -50,25 +82,35 @@ export function ForecastMetrics() {
}, },
}) })
const hasPhaseData = data?.dailyForecastsByPhase && data.dailyForecastsByPhase.length > 0
return ( return (
<> <>
<CardHeader className="flex flex-row items-center justify-between pr-5"> <CardHeader className="flex flex-row items-center justify-between pr-5">
<CardTitle className="text-xl font-medium">Forecast</CardTitle> <CardTitle className="text-xl font-medium">Forecast</CardTitle>
<div className="w-[230px]"> <div className="flex items-center gap-2">
<DateRangePicker <Popover>
value={dateRange} <PopoverTrigger asChild>
onChange={(range) => { <Button variant="ghost" size="icon" className="h-8 w-8">
if (range) setDateRange(range); <Target className="h-4 w-4" />
}} </Button>
future={true} </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> </div>
</CardHeader> </CardHeader>
<CardContent className="py-0 -mb-2"> <CardContent className="py-0 -mb-2">
{error ? ( {error ? (
<div className="text-sm text-red-500">Error: {error.message}</div> <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"> <div className="flex flex-col gap-4">
@@ -77,52 +119,125 @@ export function ForecastMetrics() {
<TrendingUp className="h-4 w-4 text-muted-foreground" /> <TrendingUp className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Forecast Sales</p> <p className="text-sm font-medium text-muted-foreground">Forecast Sales</p>
</div> </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>
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-muted-foreground" /> <DollarSign className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p> <p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
</div> </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>
</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"> <div className="h-[250px] w-full">
<ResponsiveContainer width="100%" height="100%"> {isLoading ? (
<AreaChart <div className="flex h-full items-center justify-center">
data={data?.dailyForecasts || []} <div className="h-[200px] w-full animate-pulse rounded bg-muted" />
margin={{ top: 30, right: 0, left: -60, bottom: 0 }} </div>
> ) : (
<XAxis <ResponsiveContainer width="100%" height="100%">
dataKey="date" <AreaChart
tickLine={false} data={hasPhaseData ? data.dailyForecastsByPhase : (data?.dailyForecasts || [])}
axisLine={false} margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
tick={false} >
/> <XAxis
<YAxis dataKey="date"
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tick={false} tick={false}
/> />
<Tooltip <YAxis
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]} tickLine={false}
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')} axisLine={false}
/> tick={false}
<Area />
type="monotone" <RechartsTooltip
dataKey="revenue" formatter={(value: number, name: string) => {
name="Revenue" const cfg = PHASE_CONFIG[name]
stroke="#8884D8" return [formatCurrency(value), cfg?.label || name]
fill="#8884D8" }}
fillOpacity={0.2} labelFormatter={(date) => format(new Date(date + 'T00:00:00'), 'MMM d, yyyy')}
/> itemSorter={(item) => -(item.value as number || 0)}
</AreaChart> />
</ResponsiveContainer> {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> </div>
</> </>
)} )}
</CardContent> </CardContent>
</> </>
) )
} }

View File

@@ -1,32 +1,46 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" 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 { interface OverstockMetricsData {
overstockedProducts: number overstockedProducts: number
total_excess_units: number totalExcessUnits: number
total_excess_cost: number totalExcessCost: number
total_excess_retail: number totalExcessRetail: number
category_data: { categoryData: {
category: string category: string
products: number products: number
units: number units: number
cost: number cost: number
retail: number retail: number
}[] }[]
phaseBreakdown?: PhaseBreakdown[]
}
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
} }
export function OverstockMetrics() { export function OverstockMetrics() {
const { data } = useQuery<OverstockMetricsData>({ const { data, isError, isLoading } = useQuery<OverstockMetricsData>({
queryKey: ["overstock-metrics"], queryKey: ["overstock-metrics"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`) const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`)
if (!response.ok) { if (!response.ok) throw new Error('Failed to fetch overstock metrics');
throw new Error("Failed to fetch overstock metrics") return response.json();
}
return response.json()
}, },
}) })
@@ -36,37 +50,84 @@ export function OverstockMetrics() {
<CardTitle className="text-xl font-medium">Overstock</CardTitle> <CardTitle className="text-xl font-medium">Overstock</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col gap-4"> {isError ? (
<div className="flex items-baseline justify-between"> <p className="text-sm text-destructive">Failed to load overstock metrics</p>
<div className="flex items-center gap-2"> ) : (
<Package className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col gap-4">
<p className="text-sm font-medium text-muted-foreground">Overstocked Products</p> <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> </div>
<p className="text-lg font-bold">{data?.overstockedProducts.toLocaleString() || 0}</p> <div className="flex items-baseline justify-between">
</div> <div className="flex items-center gap-2">
<div className="flex items-baseline justify-between"> <Layers className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2"> <p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
<Layers className="h-4 w-4 text-muted-foreground" /> </div>
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p> {isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.totalExcessUnits.toLocaleString()}</p>
)}
</div> </div>
<p className="text-lg font-bold">{data?.total_excess_units.toLocaleString() || 0}</p> <div className="flex items-baseline justify-between">
</div> <div className="flex items-center gap-2">
<div className="flex items-baseline justify-between"> <DollarSign className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2"> <p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p>
<DollarSign className="h-4 w-4 text-muted-foreground" /> </div>
<p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p> {isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.totalExcessCost)}</p>
)}
</div> </div>
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_cost || 0)}</p> <div className="flex items-baseline justify-between">
</div> <div className="flex items-center gap-2">
<div className="flex items-baseline justify-between"> <Tag className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2"> <p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
<ShoppingCart className="h-4 w-4 text-muted-foreground" /> </div>
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p> {isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.totalExcessRetail)}</p>
)}
</div> </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>
</div> )}
</CardContent> </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 { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts" import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons import { ClipboardList, AlertCircle, Truck, DollarSign, Tag } from "lucide-react"
import { useState } from "react" import { useState } from "react"
interface PurchaseMetricsData { interface PurchaseMetricsData {
activePurchaseOrders: number // Orders that are not canceled, done, or fully received activePurchaseOrders: number
overduePurchaseOrders: number // Orders past their expected delivery date overduePurchaseOrders: number
onOrderUnits: number // Total units across all active orders onOrderUnits: number
onOrderCost: number // Total cost across all active orders onOrderCost: number
onOrderRetail: number // Total retail value across all active orders onOrderRetail: number
vendorOrders: { vendorOrders: {
vendor: string vendor: string
orders: number orders: number
@@ -34,12 +34,11 @@ const COLORS = [
const renderActiveShape = (props: any) => { const renderActiveShape = (props: any) => {
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, vendor, cost } = props; 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 words = vendor.split(' ');
const lines: string[] = []; const lines: string[] = [];
let currentLine = ''; let currentLine = '';
words.forEach((word: string) => { words.forEach((word: string) => {
if ((currentLine + ' ' + word).length <= 12) { if ((currentLine + ' ' + word).length <= 12) {
currentLine = currentLine ? `${currentLine} ${word}` : word; currentLine = currentLine ? `${currentLine} ${word}` : word;
@@ -52,151 +51,136 @@ const renderActiveShape = (props: any) => {
return ( return (
<g> <g>
<Sector <Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill} />
cx={cx} <Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius - 1} outerRadius={outerRadius + 4} fill={fill} />
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) => ( {lines.map((line, i) => (
<text <text key={i} x={cx} y={cy} dy={-20 + (i * 16)} textAnchor="middle" fill="#888888" className="text-xs">
key={i}
x={cx}
y={cy}
dy={-20 + (i * 16)}
textAnchor="middle"
fill="#888888"
className="text-xs"
>
{line} {line}
</text> </text>
))} ))}
<text <text x={cx} y={cy} dy={lines.length * 16 - 10} textAnchor="middle" fill="#000000" className="text-base font-medium">
x={cx}
y={cy}
dy={lines.length * 16 - 10}
textAnchor="middle"
fill="#000000"
className="text-base font-medium"
>
{formatCurrency(cost)} {formatCurrency(cost)}
</text> </text>
</g> </g>
); );
}; };
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
}
export function PurchaseMetrics() { export function PurchaseMetrics() {
const [activeIndex, setActiveIndex] = useState<number | undefined>(); const [activeIndex, setActiveIndex] = useState<number | undefined>();
const { data, error, isLoading } = useQuery<PurchaseMetricsData>({ const { data, isError, isLoading } = useQuery<PurchaseMetricsData>({
queryKey: ["purchase-metrics"], queryKey: ["purchase-metrics"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/purchase/metrics`) const response = await fetch(`${config.apiUrl}/dashboard/purchase/metrics`)
if (!response.ok) { if (!response.ok) throw new Error('Failed to fetch purchase metrics');
const text = await response.text(); return response.json();
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 (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading purchase metrics</div>;
return ( return (
<> <>
<CardHeader> <CardHeader>
<CardTitle className="text-xl font-medium">Purchases</CardTitle> <CardTitle className="text-xl font-medium">Purchases</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex justify-between gap-8"> {isError ? (
<div className="flex-1"> <p className="text-sm text-destructive">Failed to load purchase metrics</p>
<div className="flex flex-col gap-4"> ) : (
<div className="flex items-baseline justify-between"> <div className="flex gap-4">
<div className="flex items-center gap-2"> <div className="shrink-0">
<ClipboardList className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">Active Purchase Orders</p> <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> </div>
<p className="text-lg font-bold">{data?.activePurchaseOrders.toLocaleString() || 0}</p>
</div> </div>
<div className="flex items-baseline justify-between"> </div>
<div className="flex items-center gap-2"> <div className="min-w-0 flex-1">
<AlertCircle className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col gap-1">
<p className="text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p> <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> </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>
</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> </CardContent>
</> </>
) )
} }

View File

@@ -1,14 +1,25 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
import { Package, DollarSign, ShoppingCart } from "lucide-react" // Importing icons 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 { interface ReplenishmentMetricsData {
productsToReplenish: number productsToReplenish: number
unitsToReplenish: number unitsToReplenish: number
replenishmentCost: number replenishmentCost: number
replenishmentRetail: number replenishmentRetail: number
phaseBreakdown?: PhaseBreakdown[]
topVariants: { topVariants: {
id: number id: number
title: string title: string
@@ -21,55 +32,95 @@ interface ReplenishmentMetricsData {
}[] }[]
} }
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
}
export function ReplenishmentMetrics() { export function ReplenishmentMetrics() {
const { data, error, isLoading } = useQuery<ReplenishmentMetricsData>({ const { data, isError, isLoading } = useQuery<ReplenishmentMetricsData>({
queryKey: ["replenishment-metrics"], queryKey: ["replenishment-metrics"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`) const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`)
if (!response.ok) { if (!response.ok) throw new Error('Failed to fetch replenishment metrics');
const text = await response.text(); return response.json();
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 (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 ( return (
<> <>
<CardHeader> <CardHeader>
<CardTitle className="text-xl font-medium">Replenishment</CardTitle> <CardTitle className="text-xl font-medium">Replenishment</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col gap-4"> {isError ? (
<div className="flex items-baseline justify-between"> <p className="text-sm text-destructive">Failed to load replenishment metrics</p>
<div className="flex items-center gap-2"> ) : (
<Package className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col gap-4">
<p className="text-sm font-medium text-muted-foreground">Units to Replenish</p> <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> </div>
<p className="text-lg font-bold">{data.unitsToReplenish.toLocaleString() || 0}</p> <div className="flex items-baseline justify-between">
</div> <div className="flex items-center gap-2">
<div className="flex items-baseline justify-between"> <DollarSign className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2"> <p className="text-sm font-medium text-muted-foreground">Replenishment Cost</p>
<DollarSign className="h-4 w-4 text-muted-foreground" /> </div>
<p className="text-sm font-medium text-muted-foreground">Replenishment Cost</p> {isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.replenishmentCost)}</p>
)}
</div> </div>
<p className="text-lg font-bold">{formatCurrency(data.replenishmentCost || 0)}</p> <div className="flex items-baseline justify-between">
</div> <div className="flex items-center gap-2">
<div className="flex items-baseline justify-between"> <Tag className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2"> <p className="text-sm font-medium text-muted-foreground">Replenishment Retail</p>
<ShoppingCart className="h-4 w-4 text-muted-foreground" /> </div>
<p className="text-sm font-medium text-muted-foreground">Replenishment Retail</p> {isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.replenishmentRetail)}</p>
)}
</div> </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>
</div> )}
</CardContent> </CardContent>
</> </>
) )
} }

View File

@@ -1,127 +1,229 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" 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 { useState } from "react"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react" 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 { 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 { interface SalesData {
totalOrders: number totalOrders: number
totalUnitsSold: number totalUnitsSold: number
totalCogs: string totalCogs: number
totalRevenue: string totalRevenue: number
dailySales: { dailySales: {
date: string date: string
units: number units: number
revenue: string revenue: number
cogs: string cogs: number
}[] }[]
dailySalesByPhase?: DailyPhaseData[]
phaseBreakdown?: PhaseBreakdown[]
}
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
} }
export function SalesMetrics() { export function SalesMetrics() {
const [dateRange, setDateRange] = useState<DateRange>({ const [period, setPeriod] = useState<Period>(30);
from: addDays(new Date(), -30),
to: new Date(),
});
const { data } = useQuery<SalesData>({ const { data, isError, isLoading } = useQuery<SalesData>({
queryKey: ["sales-metrics", dateRange], queryKey: ["sales-metrics", period],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({
startDate: dateRange.from?.toISOString() || "", startDate: addDays(new Date(), -period).toISOString(),
endDate: dateRange.to?.toISOString() || "", endDate: new Date().toISOString(),
}); });
const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`) const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`)
if (!response.ok) { if (!response.ok) throw new Error("Failed to fetch sales metrics");
throw new Error("Failed to fetch sales metrics")
}
return response.json() return response.json()
}, },
}) })
const hasPhaseData = data?.dailySalesByPhase && data.dailySalesByPhase.length > 0
return ( return (
<> <>
<CardHeader className="flex flex-row items-center justify-between pr-5"> <CardHeader className="flex flex-row items-center justify-between pr-5">
<CardTitle className="text-xl font-medium">Sales</CardTitle> <CardTitle className="text-xl font-medium">Sales</CardTitle>
<div className="w-[230px]"> <Tabs value={String(period)} onValueChange={(v) => setPeriod(Number(v) as Period)}>
<DateRangePicker <TabsList>
value={dateRange} <TabsTrigger value="7">7D</TabsTrigger>
onChange={(range) => { <TabsTrigger value="30">30D</TabsTrigger>
if (range) setDateRange(range); <TabsTrigger value="90">90D</TabsTrigger>
}} </TabsList>
future={false} </Tabs>
/>
</div>
</CardHeader> </CardHeader>
<CardContent className="py-0 -mb-2"> <CardContent className="py-0 -mb-2">
<div className="flex flex-col gap-4"> {isError ? (
<div className="flex items-baseline justify-between"> <p className="text-sm text-destructive">Failed to load sales metrics</p>
<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 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> </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"> {data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (
<ResponsiveContainer width="100%" height="100%"> <div className="mt-4 space-y-1.5">
<AreaChart <p className="text-xs font-medium text-muted-foreground">Revenue By Lifecycle Phase</p>
data={data?.dailySales || []} <TooltipProvider delayDuration={0}>
margin={{ top: 30, right: 0, left: -60, bottom: 0 }} <div className="flex h-2.5 w-full overflow-hidden rounded-full">
> {data.phaseBreakdown.map((p) => {
<XAxis const cfg = PHASE_CONFIG[p.phase] || { label: p.phase, color: "#94A3B8" }
dataKey="date" return (
tickLine={false} <Tooltip key={p.phase}>
axisLine={false} <TooltipTrigger asChild>
tick={false} <div
/> className="h-full transition-all"
<YAxis style={{
tickLine={false} width: `${p.percentage}%`,
axisLine={false} backgroundColor: cfg.color,
tick={false} minWidth: p.percentage > 0 ? 3 : 0,
/> }}
<Tooltip />
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]} </TooltipTrigger>
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')} <TooltipContent side="bottom" className="text-xs">
/> <div className="flex items-center gap-1.5 font-medium">
<Area <div className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: cfg.color }} />
type="monotone" {cfg.label}
dataKey="revenue" <span className="font-normal opacity-70">{p.percentage}%</span>
name="Revenue" </div>
stroke="#00C49F" <div className="mt-0.5 font-semibold">{formatCurrency(p.revenue)}</div>
fill="#00C49F" <div className="opacity-70">{p.units.toLocaleString()} units · {p.orders.toLocaleString()} orders</div>
fillOpacity={0.2} </TooltipContent>
/> </Tooltip>
</AreaChart> )
</ResponsiveContainer> })}
</div> </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> </CardContent>
</> </>
) )
} }

View File

@@ -2,23 +2,34 @@ import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts" import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" import { Package, PackageCheck, Layers, DollarSign, Tag } from "lucide-react"
import { useState } from "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 { interface StockMetricsData {
totalProducts: number totalProducts: number
productsInStock: number productsInStock: number
totalStockUnits: number totalStockUnits: number
totalStockCost: string totalStockCost: number
totalStockRetail: string totalStockRetail: number
brandStock: { brandStock: {
brand: string brand: string
variants: number variants: number
units: number units: number
cost: string cost: number
retail: string retail: number
}[] }[]
phaseStock?: PhaseStock[]
} }
const COLORS = [ const COLORS = [
@@ -32,171 +43,212 @@ const COLORS = [
"#FF7C43", "#FF7C43",
] ]
const renderActiveShape = (props: any) => { function wrapLabel(text: string, maxLen = 12): string[] {
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, brand, retail } = props; const words = text.split(' ');
// Split brand name into words and create lines of max 12 chars
const words = brand.split(' ');
const lines: string[] = []; const lines: string[] = [];
let currentLine = ''; let cur = '';
words.forEach((word: string) => { words.forEach((word: string) => {
if ((currentLine + ' ' + word).length <= 12) { if ((cur + ' ' + word).length <= maxLen) {
currentLine = currentLine ? `${currentLine} ${word}` : word; cur = cur ? `${cur} ${word}` : word;
} else { } else {
if (currentLine) lines.push(currentLine); if (cur) lines.push(cur);
currentLine = word; 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 ( return (
<g> <g>
<Sector <Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill} />
cx={cx} <Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius - 1} outerRadius={outerRadius + 4} fill={fill} />
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) => ( {lines.map((line, i) => (
<text <text key={i} x={cx} y={cy} dy={-20 + (i * 16)} textAnchor="middle" fill="#888888" className="text-xs">{line}</text>
key={i}
x={cx}
y={cy}
dy={-20 + (i * 16)}
textAnchor="middle"
fill="#888888"
className="text-xs"
>
{line}
</text>
))} ))}
<text <text x={cx} y={cy} dy={lines.length * 16 - 10} textAnchor="middle" fill="#000000" className="text-base font-medium">
x={cx} {formatCurrency(cost)}
y={cy}
dy={lines.length * 16 - 10}
textAnchor="middle"
fill="#000000"
className="text-base font-medium"
>
{formatCurrency(Number(retail))}
</text> </text>
</g> </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() { export function StockMetrics() {
const [activeIndex, setActiveIndex] = useState<number | undefined>(); 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"], queryKey: ["stock-metrics"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`); const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`);
if (!response.ok) { if (!response.ok) throw new Error('Failed to fetch stock metrics');
const text = await response.text(); return response.json();
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 (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading stock metrics</div>;
return ( return (
<> <>
<CardHeader> <CardHeader>
<CardTitle className="text-xl font-medium">Stock</CardTitle> <CardTitle className="text-xl font-medium">Stock</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex justify-between gap-8"> {isError ? (
<div className="flex-1"> <p className="text-sm text-destructive">Failed to load stock metrics</p>
<div className="flex flex-col gap-4"> ) : (
<div className="flex items-baseline justify-between"> <div className="flex gap-4">
<div className="flex items-center gap-2"> <div className="shrink-0">
<Package className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">Products</p> <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> </div>
<p className="text-lg font-bold">{data?.totalProducts.toLocaleString() || 0}</p>
</div> </div>
<div className="flex items-baseline justify-between"> </div>
<div className="flex items-center gap-2"> <div className="flex min-w-0 flex-1 gap-2">
<Layers className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-1 flex-col gap-1">
<p className="text-sm font-medium text-muted-foreground">Products In Stock</p> <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> </div>
<p className="text-lg font-bold">{data?.productsInStock.toLocaleString() || 0}</p>
</div> </div>
<div className="flex items-baseline justify-between"> <div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2"> <div className="text-md flex justify-center font-medium">Stock Cost By Phase</div>
<Layers className="h-4 w-4 text-muted-foreground" /> <div className="h-[180px]">
<p className="text-sm font-medium text-muted-foreground">Stock Units</p> {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> </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>
</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> </CardContent>
</> </>
) )
} }

View File

@@ -3,7 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
interface Product { interface Product {
pid: number; pid: number;
@@ -15,14 +15,22 @@ interface Product {
excess_retail: number; 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() { export function TopOverstockedProducts() {
const { data } = useQuery<Product[]>({ const { data, isError, isLoading } = useQuery<Product[]>({
queryKey: ["top-overstocked-products"], queryKey: ["top-overstocked-products"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`) const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`)
if (!response.ok) { if (!response.ok) throw new Error("Failed to fetch overstocked products");
throw new Error("Failed to fetch overstocked products")
}
return response.json() return response.json()
}, },
}) })
@@ -33,41 +41,47 @@ export function TopOverstockedProducts() {
<CardTitle className="text-xl font-medium">Top Overstocked Products</CardTitle> <CardTitle className="text-xl font-medium">Top Overstocked Products</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ScrollArea className="h-[300px] w-full"> {isError ? (
<Table> <p className="text-sm text-destructive">Failed to load overstocked products</p>
<TableHeader> ) : isLoading ? (
<TableRow> <TableSkeleton />
<TableHead>Product</TableHead> ) : (
<TableHead className="text-right">Stock</TableHead> <ScrollArea className="h-[300px] w-full">
<TableHead className="text-right">Excess</TableHead> <Table>
<TableHead className="text-right">Cost</TableHead> <TableHeader>
<TableHead className="text-right">Retail</TableHead> <TableRow>
</TableRow> <TableHead>Product</TableHead>
</TableHeader> <TableHead className="text-right">Stock</TableHead>
<TableBody> <TableHead className="text-right">Excess</TableHead>
{data?.map((product) => ( <TableHead className="text-right">Cost</TableHead>
<TableRow key={product.pid}> <TableHead className="text-right">Retail</TableHead>
<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>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {data?.map((product) => (
</ScrollArea> <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> </CardContent>
</> </>
) )
} }

View File

@@ -3,6 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import config from "@/config" import config from "@/config"
import { format } from "date-fns"
interface Product { interface Product {
pid: number; pid: number;
@@ -14,14 +15,22 @@ interface Product {
last_purchase_date: string | null; 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() { export function TopReplenishProducts() {
const { data } = useQuery<Product[]>({ const { data, isError, isLoading } = useQuery<Product[]>({
queryKey: ["top-replenish-products"], queryKey: ["top-replenish-products"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`) const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`)
if (!response.ok) { if (!response.ok) throw new Error("Failed to fetch products to replenish");
throw new Error("Failed to fetch products to replenish")
}
return response.json() return response.json()
}, },
}) })
@@ -32,41 +41,47 @@ export function TopReplenishProducts() {
<CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle> <CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ScrollArea className="max-h-[530px] w-full overflow-y-auto"> {isError ? (
<Table> <p className="text-sm text-destructive">Failed to load replenish products</p>
<TableHeader> ) : isLoading ? (
<TableRow> <TableSkeleton />
<TableHead>Product</TableHead> ) : (
<TableHead className="text-right">Stock</TableHead> <ScrollArea className="max-h-[630px] w-full overflow-y-auto">
<TableHead className="text-right">Daily Sales</TableHead> <Table>
<TableHead className="text-right">Reorder Qty</TableHead> <TableHeader>
<TableHead>Last Purchase</TableHead> <TableRow>
</TableRow> <TableHead>Product</TableHead>
</TableHeader> <TableHead className="text-right">Stock</TableHead>
<TableBody> <TableHead className="text-right">Daily Sales</TableHead>
{data?.map((product) => ( <TableHead className="text-right">Reorder Qty</TableHead>
<TableRow key={product.pid}> <TableHead>Last Purchase</TableHead>
<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>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {data?.map((product) => (
</ScrollArea> <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> </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, copyable,
alwaysShowCopy, alwaysShowCopy,
formatDisplay, formatDisplay,
rightAction,
}: { }: {
value: string; value: string;
onChange: (val: string) => void; onChange: (val: string) => void;
@@ -30,6 +31,7 @@ export function EditableInput({
copyable?: boolean; copyable?: boolean;
alwaysShowCopy?: boolean; alwaysShowCopy?: boolean;
formatDisplay?: (val: string) => string; formatDisplay?: (val: string) => string;
rightAction?: React.ReactNode;
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -106,6 +108,7 @@ export function EditableInput({
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
</button> </button>
)} )}
{rightAction}
</div> </div>
); );
} }

View File

@@ -175,7 +175,7 @@ function SortableImageCell({
src={src} src={src}
alt={`Image ${image.iid}`} alt={`Image ${image.iid}`}
className={cn( 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" isMain ? "rounded-lg" : "rounded-md"
)} )}
draggable={false} draggable={false}

View File

@@ -7,7 +7,11 @@ import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink } from "lucide-react"; 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, type ImageChanges } from "@/services/productEditor"; import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
import { EditableComboboxField } from "./EditableComboboxField"; import { EditableComboboxField } from "./EditableComboboxField";
import { EditableInput } from "./EditableInput"; import { EditableInput } from "./EditableInput";
@@ -207,6 +211,8 @@ export function ProductEditForm({
handleSubmit, handleSubmit,
reset, reset,
watch, watch,
setValue,
getValues,
formState: { dirtyFields }, formState: { dirtyFields },
} = useForm<ProductFormValues>(); } = useForm<ProductFormValues>();
@@ -411,6 +417,62 @@ export function ProductEditForm({
[fieldOptions, lineOptions, sublineOptions] [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()) 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]);
const hasImageChanges = computeImageChanges() !== null; const hasImageChanges = computeImageChanges() !== null;
const changedCount = Object.keys(dirtyFields).length; const changedCount = Object.keys(dirtyFields).length;
@@ -483,9 +545,42 @@ export function ProductEditForm({
); );
} }
if (fc.type === "textarea") { if (fc.type === "textarea") {
const isDescription = fc.key === "description";
return ( 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"> <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 && (
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" /> <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> </div>
); );
@@ -499,7 +594,7 @@ export function ProductEditForm({
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-start justify-between"> <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"> <div className="flex-1 min-w-0">
<Controller <Controller
name="name" name="name"
@@ -512,9 +607,40 @@ export function ProductEditForm({
placeholder="Product name" placeholder="Product name"
className="text-base font-semibold" className="text-base font-semibold"
inputClassName="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={() => {
setValue("name", nameResult.suggestion!, { shouldDirty: true });
clearNameResult();
}}
onDismiss={clearNameResult}
compact
className="mt-1"
/>
)}
</div> </div>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -611,6 +737,36 @@ export function ProductEditForm({
renderFieldGroup(group, gi + MODE_LAYOUTS[layoutMode].sidebarGroups) 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);
}}
/>
</DialogContent>
</Dialog>
)}
{/* Submit */} {/* Submit */}
<div className="flex items-center justify-end gap-3 pt-2"> <div className="flex items-center justify-end gap-3 pt-2">
<Button <Button

View File

@@ -193,18 +193,6 @@ export const BASE_IMPORT_FIELDS = [
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 120, 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", label: "Length",
key: "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" }, { 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", label: "Shipping Restrictions",
key: "ship_restrictions", key: "ship_restrictions",

View File

@@ -16,8 +16,9 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } 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 { Button } from '@/components/ui/button';
import { AiDescriptionCompare } from '@/components/ai/AiDescriptionCompare';
import type { Field, SelectOption } from '../../../../types'; import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types'; import type { ValidationError } from '../../store/types';
import { useValidationStore } from '../../store/validationStore'; import { useValidationStore } from '../../store/validationStore';
@@ -67,8 +68,6 @@ const MultilineInputComponent = ({
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const [editValue, setEditValue] = useState(''); const [editValue, setEditValue] = useState('');
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null); const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
const [editedSuggestion, setEditedSuggestion] = useState('');
const [popoverWidth, setPopoverWidth] = useState(400); const [popoverWidth, setPopoverWidth] = useState(400);
const [popoverHeight, setPopoverHeight] = useState<number | undefined>(undefined); const [popoverHeight, setPopoverHeight] = useState<number | undefined>(undefined);
const resizeContainerRef = useRef<HTMLDivElement>(null); const resizeContainerRef = useRef<HTMLDivElement>(null);
@@ -77,12 +76,8 @@ const MultilineInputComponent = ({
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes // Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
const intentionalCloseRef = useRef(false); const intentionalCloseRef = useRef(false);
const mainTextareaRef = useRef<HTMLTextAreaElement>(null); const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
// Tracks the value when popover opened, to detect actual changes // Tracks the value when popover opened, to detect actual changes
const initialEditValueRef = useRef(''); 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 // Get the product name for this row from the store
const productName = useValidationStore( const productName = useValidationStore(
@@ -121,13 +116,6 @@ const MultilineInputComponent = ({
} }
}, [value, localDisplayValue]); }, [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 // Auto-resize a textarea to fit its content
const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement | null) => { const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement | null) => {
if (!textarea) return; if (!textarea) return;
@@ -145,61 +133,25 @@ const MultilineInputComponent = ({
} }
}, [popoverOpen, editValue, autoResizeTextarea]); }, [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(() => { useEffect(() => {
if (aiSuggestionExpanded || (popoverOpen && hasAiSuggestion)) { if (!popoverOpen || hasAiSuggestion) { setPopoverHeight(undefined); return; }
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; }
const isDesktop = window.matchMedia('(min-width: 1024px)').matches; const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
if (!isDesktop) { setPopoverHeight(undefined); return; } if (!isDesktop) { setPopoverHeight(undefined); return; }
const rafId = requestAnimationFrame(() => { const rafId = requestAnimationFrame(() => {
const main = mainTextareaRef.current; const main = mainTextareaRef.current;
const suggestion = suggestionTextareaRef.current;
const container = resizeContainerRef.current; const container = resizeContainerRef.current;
if (!container) return; if (!container) return;
// Get textarea natural content heights
const mainScrollH = main ? main.scrollHeight : 0; 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 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); const maxHeight = Math.floor(window.innerHeight * 0.7);
setPopoverHeight(Math.max(Math.min(naturalHeight, maxHeight), 200)); setPopoverHeight(Math.max(Math.min(naturalHeight, maxHeight), 200));
}); });
return () => cancelAnimationFrame(rafId); 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]); }, [popoverOpen, hasAiSuggestion]);
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside) // Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
@@ -261,7 +213,6 @@ const MultilineInputComponent = ({
// Immediately close popover // Immediately close popover
setPopoverOpen(false); setPopoverOpen(false);
setAiSuggestionExpanded(false);
// Prevent reopening this same cell // Prevent reopening this same cell
preventReopenRef.current = true; preventReopenRef.current = true;
@@ -291,7 +242,6 @@ const MultilineInputComponent = ({
} }
setPopoverOpen(false); setPopoverOpen(false);
setAiSuggestionExpanded(false);
// Signal to other cells that a popover just closed via click-outside // Signal to other cells that a popover just closed via click-outside
setCellPopoverClosed(); setCellPopoverClosed();
@@ -322,23 +272,19 @@ const MultilineInputComponent = ({
autoResizeTextarea(e.target); autoResizeTextarea(e.target);
}, [autoResizeTextarea]); }, [autoResizeTextarea]);
// Handle accepting the AI suggestion (possibly edited) // Handle accepting the AI suggestion (possibly edited) via AiDescriptionCompare
const handleAcceptSuggestion = useCallback(() => { const handleAcceptSuggestion = useCallback((text: string) => {
// Use the edited suggestion setEditValue(text);
setEditValue(editedSuggestion); setLocalDisplayValue(text);
setLocalDisplayValue(editedSuggestion); onBlur(text);
// onBlur handles both cell update and validation onDismissAiSuggestion?.();
onBlur(editedSuggestion);
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
setAiSuggestionExpanded(false);
intentionalCloseRef.current = true; intentionalCloseRef.current = true;
setPopoverOpen(false); setPopoverOpen(false);
}, [editedSuggestion, onBlur, onDismissAiSuggestion]); }, [onBlur, onDismissAiSuggestion]);
// Handle dismissing the AI suggestion // Handle dismissing the AI suggestion via AiDescriptionCompare
const handleDismissSuggestion = useCallback(() => { const handleDismissSuggestion = useCallback(() => {
onDismissAiSuggestion?.(); onDismissAiSuggestion?.();
setAiSuggestionExpanded(false);
intentionalCloseRef.current = true; intentionalCloseRef.current = true;
setPopoverOpen(false); setPopoverOpen(false);
}, [onDismissAiSuggestion]); }, [onDismissAiSuggestion]);
@@ -380,7 +326,6 @@ const MultilineInputComponent = ({
return; return;
} }
updatePopoverWidth(); updatePopoverWidth();
setAiSuggestionExpanded(true);
setPopoverOpen(true); setPopoverOpen(true);
// Initialize edit value and track it for change detection // Initialize edit value and track it for change detection
const initValue = localDisplayValue || String(value ?? ''); const initValue = localDisplayValue || String(value ?? '');
@@ -436,7 +381,12 @@ const MultilineInputComponent = ({
> >
<div <div
ref={resizeContainerRef} 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} style={popoverHeight ? { height: popoverHeight } : undefined}
> >
{/* Close button */} {/* Close button */}
@@ -449,116 +399,27 @@ const MultilineInputComponent = ({
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
{/* Main textarea */} {hasAiSuggestion ? (
<div data-col="left" className={cn("flex flex-col min-h-0 w-full", hasAiSuggestion && "lg:w-1/2")}> <AiDescriptionCompare
<div className={cn(hasAiSuggestion ? 'px-3 py-2 bg-accent' : '', 'flex flex-col flex-1 min-h-0')}> currentValue={editValue}
{/* Product name - shown inline on mobile, in measured spacer on desktop */} onCurrentChange={setEditValue}
{hasAiSuggestion && productName && ( suggestion={aiSuggestion.suggestion!}
<div className="flex-shrink-0 flex flex-col lg:hidden px-1 mb-2"> issues={aiIssues}
<div className="text-sm font-medium text-foreground mb-1">Editing description for:</div> productName={productName}
<div className="text-md font-semibold text-foreground line-clamp-1">{productName}</div> onAccept={handleAcceptSuggestion}
</div> onDismiss={handleDismissSuggestion}
)} />
{hasAiSuggestion && aiHeaderHeight > 0 && ( ) : (
<div className="flex-shrink-0 hidden lg:flex items-start" style={{ height: aiHeaderHeight }}> <div data-col="left" className="flex flex-col min-h-0 w-full">
{productName && ( <Textarea
<div className="flex flex-col"> ref={mainTextareaRef}
<div className="text-sm font-medium text-foreground px-1 mb-1">Editing description for:</div> value={editValue}
<div className="text-md font-semibold text-foreground line-clamp-1 px-1">{productName}</div> onChange={handleChange}
</div> 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"
</div> placeholder={`Enter ${field.label || 'text'}...`}
)} autoFocus
{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>
</div> </div>
)} )}
</div> </div>

View File

@@ -32,6 +32,7 @@ import type {
InlineAiValidationResult, InlineAiValidationResult,
} from './types'; } from './types';
import type { Field, SelectOption } from '../../../types'; import type { Field, SelectOption } from '../../../types';
import { stripPriceFormatting } from '../utils/priceUtils';
// ============================================================================= // =============================================================================
// Initial State // Initial State
@@ -165,11 +166,24 @@ export const useValidationStore = create<ValidationStore>()(
// Apply fresh state first (clean slate) // Apply fresh state first (clean slate)
Object.assign(state, freshState); Object.assign(state, freshState);
// Then set up with new data // Identify price fields to clean on ingestion (strips $, commas, whitespace)
state.rows = data.map((row) => ({ const priceFieldKeys = fields
...row, .filter((f) => f.fieldType.type === 'input' && 'price' in f.fieldType && f.fieldType.price)
__index: row.__index || uuidv4(), .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)); state.originalRows = JSON.parse(JSON.stringify(state.rows));
// Cast to bypass immer's strict readonly type checking // Cast to bypass immer's strict readonly type checking
state.fields = fields as unknown as typeof state.fields; state.fields = fields as unknown as typeof state.fields;

View File

@@ -2,10 +2,84 @@
* Price field cleaning and formatting utilities * 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 * 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 * - Converts to number and formats with 2 decimal places
* - Returns original value if conversion fails * - Returns original value if conversion fails
* *
@@ -14,13 +88,14 @@
* *
* @example * @example
* cleanPriceField("$1,234.56") // "1234.56" * cleanPriceField("$1,234.56") // "1234.56"
* cleanPriceField("$99.9") // "99.90" * cleanPriceField(" $ 99.9") // "99.90"
* cleanPriceField(123.456) // "123.46" * cleanPriceField("1,50") // "1.50"
* cleanPriceField("invalid") // "invalid" * cleanPriceField(123.456) // "123.46"
* cleanPriceField("invalid") // "invalid"
*/ */
export function cleanPriceField(value: string | number): string { export function cleanPriceField(value: string | number): string {
if (typeof value === "string") { if (typeof value === "string") {
const cleaned = value.replace(/[$,]/g, ""); const cleaned = stripPriceFormatting(value);
const numValue = parseFloat(cleaned); const numValue = parseFloat(cleaned);
if (!isNaN(numValue)) { if (!isNaN(numValue)) {
return numValue.toFixed(2); return numValue.toFixed(2);
@@ -59,4 +134,4 @@ export function cleanPriceFields<T extends Record<string, any>>(
} }
return cleaned; return cleaned;
} }

View File

@@ -20,7 +20,7 @@
*/ */
export function cleanPriceField(value: string | number): string { export function cleanPriceField(value: string | number): string {
if (typeof value === "string") { if (typeof value === "string") {
const cleaned = value.replace(/[$,]/g, ""); const cleaned = value.replace(/[\s$,]/g, "");
const numValue = parseFloat(cleaned); const numValue = parseFloat(cleaned);
if (!isNaN(numValue)) { if (!isNaN(numValue)) {
return numValue.toFixed(2); return numValue.toFixed(2);

View File

@@ -19,8 +19,9 @@ import { StatusBadge } from "@/components/products/StatusBadge";
import { transformMetricsRow } from "@/utils/transformUtils"; import { transformMetricsRow } from "@/utils/transformUtils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import config from "@/config"; 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 { Badge } from "@/components/ui/badge";
import { format } from "date-fns";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
// Interfaces for POs and time series data // Interfaces for POs and time series data
@@ -46,6 +47,26 @@ interface ProductTimeSeries {
recentPurchases: ProductPurchaseOrder[]; 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 { interface ProductDetailProps {
productId: number | null; productId: number | null;
onClose: () => void; onClose: () => void;
@@ -109,6 +130,18 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
enabled: !!productId, // Only run query when productId is truthy 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) // Get PO status display names (DB stores text statuses)
const getPOStatusName = (status: string): string => { const getPOStatusName = (status: string): string => {
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
@@ -328,6 +361,72 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
</CardContent> </CardContent>
</Card> </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> <Card>
<CardHeader><CardTitle className="text-base">Sales Performance (30 Days)</CardTitle></CardHeader> <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"> <CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
@@ -535,6 +634,8 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<Card> <Card>
<CardHeader><CardTitle className="text-base">Forecasting</CardTitle></CardHeader> <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"> <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 Units" value={formatNumber(product.replenishmentUnits)} />
<InfoItem label="Replenishment Cost" value={formatCurrency(product.replenishmentCost)} /> <InfoItem label="Replenishment Cost" value={formatCurrency(product.replenishmentCost)} />
<InfoItem label="To Order Units" value={formatNumber(product.toOrderUnits)} /> <InfoItem label="To Order Units" value={formatNumber(product.toOrderUnits)} />

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,11 +1,14 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { InventoryValueTrend } from '../components/analytics/InventoryValueTrend';
import { InventoryFlow } from '../components/analytics/InventoryFlow';
import { InventoryTrends } from '../components/analytics/InventoryTrends'; import { InventoryTrends } from '../components/analytics/InventoryTrends';
import { PortfolioAnalysis } from '../components/analytics/PortfolioAnalysis'; import { PortfolioAnalysis } from '../components/analytics/PortfolioAnalysis';
import { CapitalEfficiency } from '../components/analytics/CapitalEfficiency'; import { CapitalEfficiency } from '../components/analytics/CapitalEfficiency';
import { StockHealth } from '../components/analytics/StockHealth'; import { StockHealth } from '../components/analytics/StockHealth';
import { AgingSellThrough } from '../components/analytics/AgingSellThrough'; import { AgingSellThrough } from '../components/analytics/AgingSellThrough';
import { StockoutRisk } from '../components/analytics/StockoutRisk'; import { StockoutRisk } from '../components/analytics/StockoutRisk';
import { SeasonalPatterns } from '../components/analytics/SeasonalPatterns';
import { DiscountImpact } from '../components/analytics/DiscountImpact'; import { DiscountImpact } from '../components/analytics/DiscountImpact';
import { GrowthMomentum } from '../components/analytics/GrowthMomentum'; import { GrowthMomentum } from '../components/analytics/GrowthMomentum';
import config from '../config'; import config from '../config';
@@ -25,7 +28,7 @@ interface InventorySummary {
} }
export function Analytics() { export function Analytics() {
const { data: summary, isLoading } = useQuery<InventorySummary>({ const { data: summary, isLoading, isError } = useQuery<InventorySummary>({
queryKey: ['inventory-summary'], queryKey: ['inventory-summary'],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/inventory-summary`); const response = await fetch(`${config.apiUrl}/analytics/inventory-summary`);
@@ -41,6 +44,13 @@ export function Analytics() {
</div> </div>
{/* KPI Summary Cards */} {/* 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"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -88,7 +98,7 @@ export function Analytics() {
<> <>
<div className="text-2xl font-bold">{summary.gmroi.toFixed(2)}</div> <div className="text-2xl font-bold">{summary.gmroi.toFixed(2)}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
profit per $ invested (30d) annualized profit per $ invested
</p> </p>
</> </>
)} )}
@@ -96,7 +106,7 @@ export function Analytics() {
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Stock Cover</CardTitle> <CardTitle className="text-sm font-medium">Median Stock Cover</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -114,28 +124,37 @@ export function Analytics() {
</Card> </Card>
</div> </div>
{/* Section 2: Inventory Value Trends */} {/* Section 2: Inventory Value Over Time */}
<InventoryValueTrend />
{/* Section 3: Inventory Flow — Receiving vs Selling */}
<InventoryFlow />
{/* Section 4: Daily Sales Activity & Stockouts */}
<InventoryTrends /> <InventoryTrends />
{/* Section 3: ABC Portfolio Analysis */} {/* Section 5: ABC Portfolio Analysis */}
<PortfolioAnalysis /> <PortfolioAnalysis />
{/* Section 4: Capital Efficiency */} {/* Section 6: Capital Efficiency */}
<CapitalEfficiency /> <CapitalEfficiency />
{/* Section 5: Demand & Stock Health */} {/* Section 7: Demand & Stock Health */}
<StockHealth /> <StockHealth />
{/* Section 6: Aging & Sell-Through */} {/* Section 8: Aging & Sell-Through */}
<AgingSellThrough /> <AgingSellThrough />
{/* Section 7: Reorder Risk */} {/* Section 9: Reorder Risk */}
<StockoutRisk /> <StockoutRisk />
{/* Section 8: Discount Impact */} {/* Section 10: Seasonal Patterns */}
<SeasonalPatterns />
{/* Section 11: Discount Impact */}
<DiscountImpact /> <DiscountImpact />
{/* Section 9: YoY Growth Momentum */} {/* Section 12: YoY Growth Momentum */}
<GrowthMomentum /> <GrowthMomentum />
</motion.div> </motion.div>
); );

View File

@@ -18,11 +18,11 @@ export function Overview() {
</div> </div>
{/* First row - Stock and Purchase metrics */} {/* First row - Stock and Purchase metrics */}
<div className="grid gap-4 grid-cols-2"> <div className="grid gap-4 grid-cols-7">
<Card className="col-span-1"> <Card className="col-span-4">
<StockMetrics /> <StockMetrics />
</Card> </Card>
<Card className="col-span-1"> <Card className="col-span-3">
<PurchaseMetrics /> <PurchaseMetrics />
</Card> </Card>
</div> </div>

View File

@@ -5,6 +5,7 @@ import CategoryMetricsCard from "../components/purchase-orders/CategoryMetricsCa
import PaginationControls from "../components/purchase-orders/PaginationControls"; import PaginationControls from "../components/purchase-orders/PaginationControls";
import PurchaseOrdersTable from "../components/purchase-orders/PurchaseOrdersTable"; import PurchaseOrdersTable from "../components/purchase-orders/PurchaseOrdersTable";
import FilterControls from "../components/purchase-orders/FilterControls"; import FilterControls from "../components/purchase-orders/FilterControls";
import PipelineCard from "../components/purchase-orders/PipelineCard";
interface PurchaseOrder { interface PurchaseOrder {
id: number | string; id: number | string;
@@ -450,6 +451,10 @@ export default function PurchaseOrders() {
/> />
</div> </div>
<div className="mb-4">
<PipelineCard />
</div>
<FilterControls <FilterControls
searchInput={searchInput} searchInput={searchInput}
setSearchInput={setSearchInput} setSearchInput={setSearchInput}

View File

@@ -1,4 +1,5 @@
export function formatCurrency(value: number): string { 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_000) return `$${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}k`; if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}k`;
return `$${value.toFixed(0)}`; 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