20 Commits

Author SHA1 Message Date
c344fdc3b8 Fix a few product editor issues, normalize prices on spreadsheet import 2026-03-05 10:45:39 -05:00
ebef903f3b Switch column order in import 2026-02-26 17:00:11 -05:00
16d2399de8 Restore accidentally removed files, a few forecast tweaks 2026-02-24 11:13:19 -05:00
c3e09d5fd1 Add AI name/description validation to product editor 2026-02-17 09:54:37 -05:00
bae8c575bc Overview tweaks 2026-02-13 23:18:45 -05:00
45ded53530 Add in forecasting, lifecycle phases, associated component and script changes 2026-02-13 22:45:18 -05:00
f41b5ab0f6 Clean up inventory overview page 2026-02-09 22:59:34 -05:00
6834a77a80 Updates for new analytics page + add pipeline chart to PO page 2026-02-09 12:32:13 -05:00
38b12c188f Restore accidentally removed files, a few additional import/calculation fixes 2026-02-09 10:19:35 -05:00
6aefc1b40d Optimize orders import 2026-02-09 09:03:21 -05:00
7c41a7f799 Import/metrics calc fixes 2026-02-08 22:44:57 -05:00
12cc7a4639 Fixes for metrics calculations 2026-02-07 21:34:42 -05:00
9b2f9016f6 Redo analytics page 2026-02-07 13:44:51 -05:00
8044771301 Updates and fixes for products page 2026-02-07 09:30:22 -05:00
b5469440bf Add payroll and operations dashboard components 2026-02-06 10:45:34 -05:00
fd14af0f9e Stat cards fixes, mini component tweaks 2026-02-04 12:24:11 -05:00
a703019b0b Newsletter recommendations tweaks, add enter to blur on validation table 2026-02-03 14:45:13 -05:00
2744e82264 Newsletter recommendation tweaks, add campaign history dialog 2026-02-01 17:37:08 -05:00
450fd96e19 Add newsletter recommendations 2026-01-31 22:04:49 -05:00
4372dc5e26 Add Klaviyo campaign product sync script 2026-01-31 14:21:30 -05:00
129 changed files with 16879 additions and 6025 deletions

5
.gitignore vendored
View File

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

View File

@@ -1,2 +1,4 @@
* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead.
* If you use the task tool to have an agent investigate something, make sure to let it know to avoid using glob
* Prefer solving tasks in a single session. Only spawn subagents for genuinely independent workstreams.
* The postgres/query tool is not working and not connected to the current version of the database. If you need to query the database for any reason you can use "ssh netcup" and use psql on the server with inventory_readonly 6D3GUkxuFgi2UghwgnUd

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)

1106
docs/prod_registry.class.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -55,6 +55,7 @@ router.get('/stats', async (req, res) => {
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
// Main order stats query (optionally excludes Cherry Box orders)
// Note: order_status > 15 excludes cancelled (15), so cancelled stats are queried separately
const mainStatsQuery = `
SELECT
COUNT(*) as orderCount,
@@ -64,10 +65,7 @@ router.get('/stats', async (req, res) => {
AVG(stats_prod_pieces) as averageItemsPerOrder,
SUM(CASE WHEN stats_waiting_preorder > 0 THEN 1 ELSE 0 END) as preOrderCount,
SUM(CASE WHEN ship_method_selected = 'localpickup' THEN 1 ELSE 0 END) as localPickupCount,
SUM(CASE WHEN ship_method_selected = 'holdit' THEN 1 ELSE 0 END) as onHoldCount,
SUM(CASE WHEN order_status IN (100, 92) THEN 1 ELSE 0 END) as shippedCount,
SUM(CASE WHEN order_status = 15 THEN 1 ELSE 0 END) as cancelledCount,
SUM(CASE WHEN order_status = 15 THEN summary_total ELSE 0 END) as cancelledTotal
SUM(CASE WHEN ship_method_selected = 'holdit' THEN 1 ELSE 0 END) as onHoldCount
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
`;
@@ -75,6 +73,21 @@ router.get('/stats', async (req, res) => {
const [mainStats] = await connection.execute(mainStatsQuery, params);
const stats = mainStats[0];
// Cancelled orders query - uses date_cancelled instead of date_placed
// Shows orders cancelled during the selected period, regardless of when they were placed
const cancelledQuery = `
SELECT
COUNT(*) as cancelledCount,
SUM(summary_total) as cancelledTotal
FROM _order
WHERE order_status = 15
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_cancelled')}
`;
const [cancelledResult] = await connection.execute(cancelledQuery, params);
const cancelledStats = cancelledResult[0] || { cancelledCount: 0, cancelledTotal: 0 };
// Refunds query (optionally excludes Cherry Box orders)
const refundsQuery = `
SELECT
@@ -87,6 +100,19 @@ router.get('/stats', async (req, res) => {
const [refundStats] = await connection.execute(refundsQuery, params);
// Shipped orders query - uses date_shipped instead of date_placed
// This counts orders that were SHIPPED during the selected period, regardless of when they were placed
const shippedQuery = `
SELECT COUNT(*) as shippedCount
FROM _order
WHERE order_status IN (92, 95, 100)
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_shipped')}
`;
const [shippedResult] = await connection.execute(shippedQuery, params);
const shippedCount = parseInt(shippedResult[0]?.shippedCount || 0);
// Best revenue day query (optionally excludes Cherry Box orders)
const bestDayQuery = `
SELECT
@@ -102,7 +128,7 @@ router.get('/stats', async (req, res) => {
const [bestDayResult] = await connection.execute(bestDayQuery, params);
// Peak hour query (for single day periods, optionally excludes Cherry Box orders)
// Peak hour query - uses selected time range for the card value
let peakHour = null;
if (['today', 'yesterday'].includes(timeRange)) {
const peakHourQuery = `
@@ -123,46 +149,100 @@ router.get('/stats', async (req, res) => {
date.setHours(hour, 0, 0);
peakHour = {
hour,
count: peakHourResult[0].count,
count: parseInt(peakHourResult[0].count),
displayHour: date.toLocaleString("en-US", { hour: "numeric", hour12: true })
};
}
}
// Brands and categories query - simplified for now since we don't have the category tables
// We'll use a simple approach without company table for now (optionally excludes Cherry Box orders)
// Hourly breakdown for detail chart - always rolling 24 hours (like revenue/orders use 30 days)
// Returns data ordered chronologically: [24hrs ago, 23hrs ago, ..., 1hr ago, current hour]
let hourlyOrders = null;
if (['today', 'yesterday'].includes(timeRange)) {
// Get hourly counts AND current hour from MySQL to avoid timezone mismatch
const hourlyQuery = `
SELECT
HOUR(date_placed) as hour,
COUNT(*) as count,
HOUR(NOW()) as currentHour
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCB)}
AND date_placed >= NOW() - INTERVAL 24 HOUR
GROUP BY HOUR(date_placed)
`;
const [hourlyResult] = await connection.execute(hourlyQuery);
// Get current hour from MySQL (same timezone as the WHERE clause)
const currentHour = hourlyResult.length > 0 ? parseInt(hourlyResult[0].currentHour) : new Date().getHours();
// Build map of hour -> count
const hourCounts = {};
hourlyResult.forEach(row => {
hourCounts[parseInt(row.hour)] = parseInt(row.count);
});
// Build array in chronological order starting from (currentHour + 1) which is 24 hours ago
hourlyOrders = [];
for (let i = 0; i < 24; i++) {
const hour = (currentHour + 1 + i) % 24; // Start from 24hrs ago, end at current hour
hourlyOrders.push({
hour,
count: hourCounts[hour] || 0
});
}
}
// Brands query - products.company links to product_categories.cat_id for brand name
// Only include products that have a brand assigned (INNER JOIN)
const brandsQuery = `
SELECT
'Various Brands' as brandName,
pc.cat_id as catId,
pc.name as brandName,
COUNT(DISTINCT oi.order_id) as orderCount,
SUM(oi.qty_ordered) as itemCount,
SUM(oi.qty_ordered * oi.prod_price) as revenue
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
JOIN products p ON oi.prod_pid = p.pid
JOIN product_categories pc ON p.company = pc.cat_id
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
GROUP BY pc.cat_id, pc.name
HAVING revenue > 0
ORDER BY revenue DESC
LIMIT 100
`;
const [brandsResult] = await connection.execute(brandsQuery, params);
// For categories, we'll use a simplified approach (optionally excludes Cherry Box orders)
// Categories query - uses product_category_index to get category assignments
// Only include categories with valid types (no NULL/uncategorized)
const categoriesQuery = `
SELECT
'General' as categoryName,
pc.cat_id as catId,
pc.name as categoryName,
COUNT(DISTINCT oi.order_id) as orderCount,
SUM(oi.qty_ordered) as itemCount,
SUM(oi.qty_ordered * oi.prod_price) as revenue
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
JOIN products p ON oi.prod_pid = p.pid
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
JOIN product_category_index pci ON p.pid = pci.pid
JOIN product_categories pc ON pci.cat_id = pc.cat_id
WHERE o.order_status > 15
AND ${getCherryBoxClauseAliased('o', excludeCB)}
AND ${whereClause.replace('date_placed', 'o.date_placed')}
AND pc.type IN (10, 20, 11, 21, 12, 13)
GROUP BY pc.cat_id, pc.name
HAVING revenue > 0
ORDER BY revenue DESC
LIMIT 100
`;
const [categoriesResult] = await connection.execute(categoriesQuery, params);
// Shipping locations query (optionally excludes Cherry Box orders)
// Shipping locations query - uses date_shipped to match shippedCount
const shippingQuery = `
SELECT
ship_country,
@@ -170,19 +250,22 @@ router.get('/stats', async (req, res) => {
ship_method_selected,
COUNT(*) as count
FROM _order
WHERE order_status IN (100, 92) AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
WHERE order_status IN (92, 95, 100)
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_shipped')}
GROUP BY ship_country, ship_state, ship_method_selected
`;
const [shippingResult] = await connection.execute(shippingQuery, params);
// Process shipping data
const shippingStats = processShippingData(shippingResult, stats.shippedCount);
const shippingStats = processShippingData(shippingResult, shippedCount);
// Order value range query (optionally excludes Cherry Box orders)
// Excludes $0 orders from min calculation
const orderRangeQuery = `
SELECT
MIN(summary_total) as smallest,
MIN(CASE WHEN summary_total > 0 THEN summary_total END) as smallest,
MAX(summary_total) as largest
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
@@ -226,7 +309,7 @@ router.get('/stats', async (req, res) => {
// Shipping
shipping: {
shippedCount: parseInt(stats.shippedCount || 0),
shippedCount: parseInt(shippedCount || 0),
locations: shippingStats.locations,
methodStats: shippingStats.methods
},
@@ -235,6 +318,7 @@ router.get('/stats', async (req, res) => {
brands: {
total: brandsResult.length,
list: brandsResult.slice(0, 50).map(brand => ({
id: brand.catId,
name: brand.brandName,
count: parseInt(brand.itemCount),
revenue: parseFloat(brand.revenue)
@@ -244,6 +328,7 @@ router.get('/stats', async (req, res) => {
categories: {
total: categoriesResult.length,
list: categoriesResult.slice(0, 50).map(category => ({
id: category.catId,
name: category.categoryName,
count: parseInt(category.itemCount),
revenue: parseFloat(category.revenue)
@@ -257,8 +342,8 @@ router.get('/stats', async (req, res) => {
},
canceledOrders: {
total: parseFloat(stats.cancelledTotal || 0),
count: parseInt(stats.cancelledCount || 0)
total: parseFloat(cancelledStats.cancelledTotal || 0),
count: parseInt(cancelledStats.cancelledCount || 0)
},
// Best day
@@ -270,6 +355,7 @@ router.get('/stats', async (req, res) => {
// Peak hour (for single days)
peakOrderHour: peakHour,
hourlyOrders: hourlyOrders, // Array of 24 hourly order counts for the detail chart
// Order value range
orderValueRange: orderRangeResult.length > 0 ? {
@@ -324,13 +410,125 @@ router.get('/stats', async (req, res) => {
router.get('/stats/details', async (req, res) => {
let release;
try {
const { timeRange, startDate, endDate, metric, daily, excludeCherryBox } = req.query;
const { timeRange, startDate, endDate, metric, daily, excludeCherryBox, orderType, eventType } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
const { connection, release: releaseConn } = await getDbConnection();
release = releaseConn;
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
// Handle special event types (refunds, cancellations)
if (eventType === 'PAYMENT_REFUNDED') {
// Refunds query - from order_payment table
const refundsQuery = `
SELECT
DATE(op.payment_date) as date,
COUNT(*) as count,
ABS(SUM(op.payment_amount)) as total
FROM order_payment op
JOIN _order o ON op.order_id = o.order_id
WHERE op.payment_amount < 0
AND o.order_status > 15
AND ${getCherryBoxClauseAliased('o', excludeCB)}
AND ${whereClause.replace('date_placed', 'op.payment_date')}
GROUP BY DATE(op.payment_date)
ORDER BY DATE(op.payment_date)
`;
const [refundResults] = await connection.execute(refundsQuery, params);
// Format matches what frontend expects: day.refunds.total, day.refunds.count
const stats = refundResults.map(day => ({
timestamp: day.date,
date: day.date,
refunds: {
total: parseFloat(day.total || 0),
count: parseInt(day.count || 0),
reasons: {}
}
}));
if (release) release();
return res.json({ stats });
}
if (eventType === 'CANCELED_ORDER') {
// Cancellations query - uses date_cancelled to show when orders were actually cancelled
const cancelQuery = `
SELECT
DATE(date_cancelled) as date,
COUNT(*) as count,
SUM(summary_total) as total
FROM _order
WHERE order_status = 15
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_cancelled')}
GROUP BY DATE(date_cancelled)
ORDER BY DATE(date_cancelled)
`;
const [cancelResults] = await connection.execute(cancelQuery, params);
// Format matches what frontend expects: day.canceledOrders.total, day.canceledOrders.count
const stats = cancelResults.map(day => ({
timestamp: day.date,
date: day.date,
canceledOrders: {
total: parseFloat(day.total || 0),
count: parseInt(day.count || 0),
reasons: {}
}
}));
if (release) release();
return res.json({ stats });
}
if (eventType === 'PLACED_ORDER') {
// Order range query - daily min/max/average order values
const orderRangeQuery = `
SELECT
DATE(date_placed) as date,
COUNT(*) as orders,
MIN(CASE WHEN summary_total > 0 THEN summary_total END) as smallest,
MAX(summary_total) as largest,
AVG(summary_total) as averageOrderValue
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause}
GROUP BY DATE(date_placed)
ORDER BY DATE(date_placed)
`;
const [orderRangeResults] = await connection.execute(orderRangeQuery, params);
// Format matches what frontend OrderRangeDetails expects
const stats = orderRangeResults.map(day => ({
timestamp: day.date,
date: day.date,
orders: parseInt(day.orders || 0),
orderValueRange: {
smallest: parseFloat(day.smallest || 0),
largest: parseFloat(day.largest || 0)
},
averageOrderValue: parseFloat(day.averageOrderValue || 0)
}));
if (release) release();
return res.json({ stats });
}
// Build order type filter based on orderType parameter
let orderTypeFilter = '';
if (orderType === 'pre_orders') {
orderTypeFilter = 'AND stats_waiting_preorder > 0';
} else if (orderType === 'local_pickup') {
orderTypeFilter = "AND ship_method_selected = 'localpickup'";
} else if (orderType === 'on_hold') {
orderTypeFilter = "AND ship_method_selected = 'holdit'";
}
// Daily breakdown query (optionally excludes Cherry Box orders)
const dailyQuery = `
SELECT
@@ -340,7 +538,7 @@ router.get('/stats/details', async (req, res) => {
AVG(summary_total) as averageOrderValue,
SUM(stats_prod_pieces) as itemCount
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} ${orderTypeFilter}
GROUP BY DATE(date_placed)
ORDER BY DATE(date_placed)
`;
@@ -376,7 +574,7 @@ router.get('/stats/details', async (req, res) => {
SUM(summary_total) as prevRevenue,
AVG(summary_total) as prevAvgOrderValue
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause}
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause} ${orderTypeFilter}
GROUP BY DATE(date_placed)
`;
@@ -619,18 +817,23 @@ router.get('/products', async (req, res) => {
// Projection endpoint - replaces /api/klaviyo/events/projection
router.get('/projection', async (req, res) => {
const startTime = Date.now();
let release;
try {
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
console.log(`[PROJECTION] Starting request for timeRange: ${timeRange}`);
// Only provide projections for incomplete periods
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
return res.json({ projectedRevenue: 0, confidence: 0 });
return res.json({ projectedRevenue: 0, confidence: 0, method: 'none' });
}
const { connection, release: releaseConn } = await getDbConnection();
release = releaseConn;
console.log(`[PROJECTION] DB connection obtained in ${Date.now() - startTime}ms`);
const now = DateTime.now().setZone(TIMEZONE);
// Get current period data
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
@@ -646,23 +849,42 @@ router.get('/projection', async (req, res) => {
const [currentResult] = await connection.execute(currentQuery, params);
const current = currentResult[0];
console.log(`[PROJECTION] Current period data fetched in ${Date.now() - startTime}ms`);
// Get historical data for the same period type
const historicalQuery = await getHistoricalProjectionData(connection, timeRange, excludeCB);
// Fetch pattern data in parallel for performance
const patternStart = Date.now();
const [hourlyPattern, dayOfWeekPattern, dailyStats] = await Promise.all([
getHourlyRevenuePattern(connection, excludeCB),
getDayOfWeekRevenuePattern(connection, excludeCB),
getAverageDailyRevenue(connection, excludeCB)
]);
console.log(`[PROJECTION] Pattern data fetched in ${Date.now() - patternStart}ms`);
// Calculate projection based on current progress and historical patterns
// Calculate period progress (for logging/debugging)
const periodProgress = calculatePeriodProgress(timeRange);
// Calculate pattern-based projection
const projection = calculateSmartProjection(
timeRange,
parseFloat(current.currentRevenue || 0),
parseInt(current.currentOrders || 0),
periodProgress,
historicalQuery
hourlyPattern,
dayOfWeekPattern,
dailyStats,
now
);
// Add some useful debug info
projection.periodProgress = periodProgress;
projection.currentRevenue = parseFloat(current.currentRevenue || 0);
projection.currentOrders = parseInt(current.currentOrders || 0);
console.log(`[PROJECTION] Request completed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`);
res.json(projection);
} catch (error) {
console.error('Error in /projection:', error);
console.error(`[PROJECTION] Error after ${Date.now() - startTime}ms:`, error);
res.status(500).json({ error: error.message });
} finally {
// Release connection back to pool
@@ -725,7 +947,7 @@ function processShippingData(shippingResult, totalShipped) {
return {
locations: {
total: totalShipped,
total: Object.keys(states).length, // Count of unique states/regions shipped to
byCountry: Object.entries(countries)
.map(([country, count]) => ({
country,
@@ -1049,40 +1271,491 @@ function getPreviousTimeRange(timeRange) {
return map[timeRange] || timeRange;
}
async function getHistoricalProjectionData(connection, timeRange, excludeCherryBox = false) {
// Get historical data for projection calculations (optionally excludes Cherry Box orders)
// This is a simplified version - you could make this more sophisticated
const historicalQuery = `
/**
* Get hourly revenue distribution pattern from last 8 weeks (same day of week)
* Returns array of 24 objects with hour and avgShare (0-1 representing % of daily revenue)
* Optimized: Uses JOIN instead of correlated subquery for O(n) instead of O(n²)
*/
async function getHourlyRevenuePattern(connection, excludeCherryBox = false) {
const now = DateTime.now().setZone(TIMEZONE);
const dayOfWeek = now.weekday; // 1=Monday, 7=Sunday (Luxon)
const mysqlDayOfWeek = dayOfWeek === 7 ? 1 : dayOfWeek + 1;
// Step 1: Get daily totals and hourly breakdowns in one efficient query
const query = `
SELECT
SUM(summary_total) as revenue,
COUNT(*) as orders
hourly.hour_of_day,
AVG(hourly.hour_revenue / daily.daily_revenue) as avgShare
FROM (
SELECT
DATE(date_placed) as order_date,
HOUR(date_placed) as hour_of_day,
SUM(summary_total) as hour_revenue
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
AND DAYOFWEEK(date_placed) = ?
GROUP BY DATE(date_placed), HOUR(date_placed)
) hourly
JOIN (
SELECT
DATE(date_placed) as order_date,
SUM(summary_total) as daily_revenue
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
AND DAYOFWEEK(date_placed) = ?
GROUP BY DATE(date_placed)
HAVING daily_revenue > 0
) daily ON hourly.order_date = daily.order_date
GROUP BY hourly.hour_of_day
ORDER BY hourly.hour_of_day
`;
const [result] = await connection.execute(query, [mysqlDayOfWeek, mysqlDayOfWeek]);
// Convert to a full 24-hour array, filling gaps with 0
const hourlyPattern = Array(24).fill(0).map((_, i) => ({ hour: i, avgShare: 0 }));
result.forEach(row => {
hourlyPattern[row.hour_of_day] = {
hour: row.hour_of_day,
avgShare: parseFloat(row.avgShare) || 0
};
});
// Normalize so shares sum to 1.0
const totalShare = hourlyPattern.reduce((sum, h) => sum + h.avgShare, 0);
if (totalShare > 0) {
hourlyPattern.forEach(h => h.avgShare = h.avgShare / totalShare);
}
return hourlyPattern;
}
/**
* Get day-of-week revenue distribution pattern from last 8 weeks
* Returns array of 7 objects with dayOfWeek (1-7, Sunday=1) and avgShare
* Optimized: Uses JOIN instead of correlated subquery
*/
async function getDayOfWeekRevenuePattern(connection, excludeCherryBox = false) {
const query = `
SELECT
daily.day_of_week,
AVG(daily.day_revenue / weekly.weekly_revenue) as avgShare
FROM (
SELECT
YEARWEEK(date_placed, 0) as year_week,
DAYOFWEEK(date_placed) as day_of_week,
SUM(summary_total) as day_revenue
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
GROUP BY YEARWEEK(date_placed, 0), DAYOFWEEK(date_placed)
) daily
JOIN (
SELECT
YEARWEEK(date_placed, 0) as year_week,
SUM(summary_total) as weekly_revenue
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
GROUP BY YEARWEEK(date_placed, 0)
HAVING weekly_revenue > 0
) weekly ON daily.year_week = weekly.year_week
GROUP BY daily.day_of_week
ORDER BY daily.day_of_week
`;
const [result] = await connection.execute(query);
// Convert to array indexed by MySQL day of week (1=Sunday, 2=Monday, etc.)
const weekPattern = Array(8).fill(0).map((_, i) => ({ dayOfWeek: i, avgShare: 0 }));
result.forEach(row => {
weekPattern[row.day_of_week] = {
dayOfWeek: row.day_of_week,
avgShare: parseFloat(row.avgShare) || 0
};
});
// Normalize (indices 1-7 are used, 0 is unused)
const totalShare = weekPattern.slice(1).reduce((sum, d) => sum + d.avgShare, 0);
if (totalShare > 0) {
weekPattern.forEach(d => { if (d.dayOfWeek > 0) d.avgShare = d.avgShare / totalShare; });
}
return weekPattern;
}
/**
* Get average daily revenue for projection (last 30 days, excluding today)
* Also gets same-day-of-week stats for more accurate confidence calculation
*/
async function getAverageDailyRevenue(connection, excludeCherryBox = false) {
const now = DateTime.now().setZone(TIMEZONE);
const mysqlDayOfWeek = now.weekday === 7 ? 1 : now.weekday + 1;
// Get both overall 30-day stats AND same-day-of-week stats
const query = `
SELECT
AVG(daily_revenue) as avgDailyRevenue,
STDDEV(daily_revenue) as stdDev,
COUNT(*) as dayCount,
(
SELECT AVG(day_rev) FROM (
SELECT SUM(summary_total) as day_rev
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek}
GROUP BY DATE(date_placed)
) same_day
) as sameDayAvg,
(
SELECT STDDEV(day_rev) FROM (
SELECT SUM(summary_total) as day_rev
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek}
GROUP BY DATE(date_placed)
) same_day_std
) as sameDayStdDev,
(
SELECT COUNT(*) FROM (
SELECT DATE(date_placed) as d
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek}
GROUP BY DATE(date_placed)
) same_day_count
) as sameDayCount
FROM (
SELECT
DATE(date_placed) as order_date,
SUM(summary_total) as daily_revenue
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY)
AND date_placed < DATE_SUB(NOW(), INTERVAL 1 DAY)
AND date_placed < DATE(NOW())
GROUP BY DATE(date_placed)
) daily_totals
`;
const [result] = await connection.execute(historicalQuery);
return result;
const [result] = await connection.execute(query);
const row = result[0] || {};
return {
avgDailyRevenue: parseFloat(row.avgDailyRevenue) || 0,
stdDev: parseFloat(row.stdDev) || 0,
dayCount: parseInt(row.dayCount) || 0,
sameDayAvg: parseFloat(row.sameDayAvg) || 0,
sameDayStdDev: parseFloat(row.sameDayStdDev) || 0,
sameDayCount: parseInt(row.sameDayCount) || 0
};
}
function calculateSmartProjection(currentRevenue, currentOrders, periodProgress, historicalData) {
/**
* Calculate meaningful confidence score based on multiple factors
* Returns score between 0-1 and breakdown of contributing factors
*/
function calculateConfidence({
expectedProgress,
currentRevenue,
patternProjection,
historicalDailyAvg,
sameDayStdDev,
sameDayCount,
stdDev,
dayCount
}) {
const factors = {};
// Factor 1: Time Progress (0-0.3)
// More time elapsed = more data = higher confidence
// Scales from 0 at 0% to 0.3 at 100%
factors.timeProgress = Math.min(0.3, expectedProgress * 0.35);
// Factor 2: Historical Predictability via Coefficient of Variation (0-0.35)
// CV = stdDev / mean - lower is more predictable
// Use same-day-of-week stats if available (more relevant)
const relevantStdDev = sameDayStdDev || stdDev || 0;
const relevantAvg = historicalDailyAvg || 1;
const cv = relevantStdDev / relevantAvg;
// CV of 0.1 (10% variation) = very predictable = full points
// CV of 0.5 (50% variation) = unpredictable = minimal points
// Scale: CV 0.1 -> 0.35, CV 0.3 -> 0.15, CV 0.5+ -> 0.05
if (cv <= 0.1) {
factors.predictability = 0.35;
} else if (cv <= 0.5) {
factors.predictability = Math.max(0.05, 0.35 - (cv - 0.1) * 0.75);
} else {
factors.predictability = 0.05;
}
// Factor 3: Tracking Accuracy (0-0.25)
// How well is today tracking the expected pattern?
// If we're at 40% progress with 38-42% of expected revenue, that's good
if (expectedProgress > 0.05 && historicalDailyAvg > 0) {
const expectedRevenueSoFar = historicalDailyAvg * expectedProgress;
const trackingRatio = currentRevenue / expectedRevenueSoFar;
// Perfect tracking (ratio = 1.0) = full points
// 20% off (ratio 0.8 or 1.2) = partial points
// 50%+ off = minimal points
const deviation = Math.abs(1 - trackingRatio);
if (deviation <= 0.1) {
factors.tracking = 0.25;
} else if (deviation <= 0.3) {
factors.tracking = 0.25 - (deviation - 0.1) * 0.5;
} else if (deviation <= 0.5) {
factors.tracking = 0.15 - (deviation - 0.3) * 0.4;
} else {
factors.tracking = 0.05;
}
} else {
// Not enough progress to judge tracking
factors.tracking = 0.1;
}
// Factor 4: Data Quality (0-0.1)
// More historical data points = more reliable pattern
const dataPoints = sameDayCount || Math.floor(dayCount / 7) || 0;
// 8 weeks of same-day data = full points, less = proportionally less
factors.dataQuality = Math.min(0.1, (dataPoints / 8) * 0.1);
// Calculate total confidence score
const score = Math.min(0.95, Math.max(0.1,
factors.timeProgress +
factors.predictability +
factors.tracking +
factors.dataQuality
));
return { score, factors };
}
/**
* Calculate pattern-based projection for different time ranges
*/
function calculateSmartProjection(
timeRange,
currentRevenue,
currentOrders,
periodProgress,
hourlyPattern,
dayOfWeekPattern,
dailyStats,
now
) {
if (periodProgress >= 100) {
return { projectedRevenue: currentRevenue, projectedOrders: currentOrders, confidence: 1.0 };
}
// Simple linear projection with confidence based on how much of the period has elapsed
const projectedRevenue = currentRevenue / (periodProgress / 100);
const projectedOrders = Math.round(currentOrders / (periodProgress / 100));
const currentHour = now.hour;
const currentDayOfWeek = now.weekday === 7 ? 1 : now.weekday + 1; // Convert to MySQL day (1=Sunday)
// Confidence increases with more data (higher period progress)
const confidence = Math.min(0.95, Math.max(0.1, periodProgress / 100));
if (timeRange === 'today') {
// Calculate expected progress based on hourly pattern
// Sum up shares for all hours up to and including current hour
let expectedProgress = 0;
for (let h = 0; h <= currentHour; h++) {
expectedProgress += hourlyPattern[h]?.avgShare || 0;
}
// Adjust for partial hour (how far through current hour we are)
const minuteProgress = now.minute / 60;
const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0;
expectedProgress = expectedProgress - currentHourShare + (currentHourShare * minuteProgress);
// Avoid division by zero and handle edge cases
if (expectedProgress <= 0.01) {
// Very early in day, use linear projection with low confidence
const linearProjection = currentRevenue / Math.max(periodProgress / 100, 0.01);
return {
projectedRevenue: linearProjection,
projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)),
confidence: 0.1,
method: 'linear_fallback'
};
}
const patternProjection = currentRevenue / expectedProgress;
// Blend with historical average for stability early in day
// Use same-day-of-week average if available, otherwise fall back to overall average
const historicalDailyAvg = dailyStats.sameDayAvg || dailyStats.avgDailyRevenue || patternProjection;
const actualWeight = Math.pow(expectedProgress, 0.8); // More weight to actual as day progresses
const projectedRevenue = (patternProjection * actualWeight) + (historicalDailyAvg * (1 - actualWeight));
// Calculate meaningful confidence based on multiple factors
const confidence = calculateConfidence({
expectedProgress,
currentRevenue,
patternProjection,
historicalDailyAvg,
sameDayStdDev: dailyStats.sameDayStdDev,
sameDayCount: dailyStats.sameDayCount,
stdDev: dailyStats.stdDev,
dayCount: dailyStats.dayCount
});
return {
projectedRevenue,
projectedOrders,
confidence
projectedOrders: Math.round(currentOrders / expectedProgress),
confidence: confidence.score,
confidenceFactors: confidence.factors,
method: 'hourly_pattern',
debug: { expectedProgress, actualWeight, patternProjection, historicalDailyAvg }
};
}
if (timeRange === 'thisWeek') {
// Calculate revenue expected so far this week based on day-of-week pattern
// And project remaining days
// Days completed so far (Sunday = day 1 in MySQL)
// If today is Tuesday (MySQL day 3), completed days are Sunday(1) and Monday(2)
let expectedProgressSoFar = 0;
for (let d = 1; d < currentDayOfWeek; d++) {
expectedProgressSoFar += dayOfWeekPattern[d]?.avgShare || 0;
}
// Add partial progress through today using hourly pattern
let todayExpectedProgress = 0;
for (let h = 0; h <= currentHour; h++) {
todayExpectedProgress += hourlyPattern[h]?.avgShare || 0;
}
const minuteProgress = now.minute / 60;
const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0;
todayExpectedProgress = todayExpectedProgress - currentHourShare + (currentHourShare * minuteProgress);
// Add today's partial contribution
const todayFullShare = dayOfWeekPattern[currentDayOfWeek]?.avgShare || (1/7);
expectedProgressSoFar += todayFullShare * todayExpectedProgress;
// Avoid division by zero
if (expectedProgressSoFar <= 0.01) {
return {
projectedRevenue: currentRevenue / Math.max(periodProgress / 100, 0.01),
projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)),
confidence: 0.1,
method: 'linear_fallback'
};
}
const projectedWeekRevenue = currentRevenue / expectedProgressSoFar;
const projectedWeekOrders = Math.round(currentOrders / expectedProgressSoFar);
// Calculate meaningful confidence
const historicalWeeklyAvg = dailyStats.avgDailyRevenue * 7;
const confidence = calculateConfidence({
expectedProgress: expectedProgressSoFar,
currentRevenue,
patternProjection: projectedWeekRevenue,
historicalDailyAvg: historicalWeeklyAvg,
sameDayStdDev: dailyStats.stdDev * Math.sqrt(7), // Approximate weekly stdDev
sameDayCount: Math.floor(dailyStats.dayCount / 7),
stdDev: dailyStats.stdDev * Math.sqrt(7),
dayCount: dailyStats.dayCount
});
return {
projectedRevenue: projectedWeekRevenue,
projectedOrders: projectedWeekOrders,
confidence: confidence.score,
confidenceFactors: confidence.factors,
method: 'weekly_pattern',
debug: { expectedProgressSoFar, currentDayOfWeek, todayExpectedProgress }
};
}
if (timeRange === 'thisMonth') {
// For month projection, use days elapsed and average daily revenue
const currentDay = now.day;
const daysInMonth = now.daysInMonth;
// Calculate average daily revenue so far this month
const daysElapsed = currentDay - 1; // Full days completed
// Add partial progress through today
let todayExpectedProgress = 0;
for (let h = 0; h <= currentHour; h++) {
todayExpectedProgress += hourlyPattern[h]?.avgShare || 0;
}
const minuteProgress = now.minute / 60;
const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0;
todayExpectedProgress = todayExpectedProgress - currentHourShare + (currentHourShare * minuteProgress);
const effectiveDaysElapsed = daysElapsed + todayExpectedProgress;
if (effectiveDaysElapsed <= 0.1) {
// Very early in month, use historical average
const projectedRevenue = dailyStats.avgDailyRevenue * daysInMonth;
return {
projectedRevenue,
projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)),
confidence: 0.15,
method: 'historical_monthly'
};
}
// Calculate implied daily rate from current data
const impliedDailyRate = currentRevenue / effectiveDaysElapsed;
// Blend with historical average (more weight to actual data as month progresses)
const actualWeight = Math.min(0.9, effectiveDaysElapsed / 10); // Full weight after ~10 days
const blendedDailyRate = (impliedDailyRate * actualWeight) + (dailyStats.avgDailyRevenue * (1 - actualWeight));
const projectedMonthRevenue = blendedDailyRate * daysInMonth;
const projectedMonthOrders = Math.round((currentOrders / effectiveDaysElapsed) * daysInMonth);
// Calculate meaningful confidence
const historicalMonthlyAvg = dailyStats.avgDailyRevenue * daysInMonth;
const confidence = calculateConfidence({
expectedProgress: effectiveDaysElapsed / daysInMonth,
currentRevenue,
patternProjection: projectedMonthRevenue,
historicalDailyAvg: historicalMonthlyAvg,
sameDayStdDev: dailyStats.stdDev * Math.sqrt(daysInMonth),
sameDayCount: 1, // Only ~1 month of same-month data typically
stdDev: dailyStats.stdDev * Math.sqrt(daysInMonth),
dayCount: dailyStats.dayCount
});
return {
projectedRevenue: projectedMonthRevenue,
projectedOrders: projectedMonthOrders,
confidence: confidence.score,
confidenceFactors: confidence.factors,
method: 'monthly_blend',
debug: { effectiveDaysElapsed, daysInMonth, impliedDailyRate, blendedDailyRate }
};
}
// Fallback for any other case
const linearProjection = currentRevenue / (periodProgress / 100);
return {
projectedRevenue: linearProjection,
projectedOrders: Math.round(currentOrders / (periodProgress / 100)),
confidence: Math.min(0.95, Math.max(0.1, periodProgress / 100)),
method: 'linear_fallback'
};
}

View File

@@ -176,16 +176,30 @@ router.get('/', async (req, res) => {
// Business day starts at 1 AM, so subtract 1 hour before taking the date
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
const pickingTrendQuery = `
SELECT
pt_agg.date,
COALESCE(order_counts.ordersPicked, 0) as ordersPicked,
pt_agg.piecesPicked
FROM (
SELECT
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked,
COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
FROM picking_ticket pt
WHERE ${pickingTrendWhere}
AND pt.closeddate IS NOT NULL
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
) pt_agg
LEFT JOIN (
SELECT
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked
FROM picking_ticket pt
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
WHERE ${pickingTrendWhere}
AND pt.closeddate IS NOT NULL
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
ORDER BY date
) order_counts ON pt_agg.date = order_counts.date
ORDER BY pt_agg.date
`;
// Get shipping trend data
@@ -203,7 +217,7 @@ router.get('/', async (req, res) => {
`;
const [[pickingTrendRows], [shippingTrendRows]] = await Promise.all([
connection.execute(pickingTrendQuery, params),
connection.execute(pickingTrendQuery, [...params, ...params]),
connection.execute(shippingTrendQuery, params),
]);

View File

@@ -234,10 +234,13 @@ function calculateHoursFromPunches(punches) {
/**
* Calculate FTE for a pay period (based on 80 hours = 1 FTE for 2-week period)
* @param {number} totalHours - Total hours worked
* @param {number} elapsedFraction - Fraction of the period elapsed (0-1). Defaults to 1 for complete periods.
*/
function calculateFTE(totalHours) {
function calculateFTE(totalHours, elapsedFraction = 1) {
const fullTimePeriodHours = STANDARD_WEEKLY_HOURS * 2; // 80 hours for 2 weeks
return totalHours / fullTimePeriodHours;
const proratedHours = fullTimePeriodHours * elapsedFraction;
return proratedHours > 0 ? totalHours / proratedHours : 0;
}
// Main payroll metrics endpoint
@@ -303,8 +306,15 @@ router.get('/', async (req, res) => {
// Calculate hours with week breakdown
const hoursData = calculateHoursByWeek(timeclockRows, payPeriod);
// Calculate FTE
const fte = calculateFTE(hoursData.totals.hours);
// Calculate FTE — prorate for in-progress periods so the value reflects
// the pace employees are on rather than raw hours / 80
let elapsedFraction = 1;
if (isCurrentPayPeriod(payPeriod)) {
const now = DateTime.now().setZone(TIMEZONE);
const elapsedDays = Math.max(1, Math.ceil(now.diff(payPeriod.start, 'days').days));
elapsedFraction = Math.min(1, elapsedDays / 14);
}
const fte = calculateFTE(hoursData.totals.hours, elapsedFraction);
const activeEmployees = hoursData.totals.activeEmployees;
const avgHoursPerEmployee = activeEmployees > 0 ? hoursData.totals.hours / activeEmployees : 0;

View File

@@ -16,6 +16,7 @@
"ioredis": "^5.4.1",
"luxon": "^3.5.0",
"node-fetch": "^3.3.2",
"pg": "^8.18.0",
"recharts": "^2.15.0"
},
"devDependencies": {
@@ -1379,6 +1380,95 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -1392,6 +1482,45 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -1809,6 +1938,15 @@
"node": ">=10"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@@ -1952,6 +2090,15 @@
"engines": {
"node": ">= 8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

View File

@@ -17,6 +17,7 @@
"ioredis": "^5.4.1",
"luxon": "^3.5.0",
"node-fetch": "^3.3.2",
"pg": "^8.18.0",
"recharts": "^2.15.0"
},
"devDependencies": {

View File

@@ -0,0 +1,30 @@
-- Stores individual product links found in Klaviyo campaign emails
CREATE TABLE IF NOT EXISTS klaviyo_campaign_products (
id SERIAL PRIMARY KEY,
campaign_id TEXT NOT NULL,
campaign_name TEXT,
sent_at TIMESTAMPTZ,
pid BIGINT NOT NULL,
product_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(campaign_id, pid)
);
CREATE INDEX IF NOT EXISTS idx_kcp_campaign_id ON klaviyo_campaign_products(campaign_id);
CREATE INDEX IF NOT EXISTS idx_kcp_pid ON klaviyo_campaign_products(pid);
CREATE INDEX IF NOT EXISTS idx_kcp_sent_at ON klaviyo_campaign_products(sent_at);
-- Stores non-product shop links (categories, filters, etc.) found in campaigns
CREATE TABLE IF NOT EXISTS klaviyo_campaign_links (
id SERIAL PRIMARY KEY,
campaign_id TEXT NOT NULL,
campaign_name TEXT,
sent_at TIMESTAMPTZ,
link_url TEXT NOT NULL,
link_type TEXT, -- 'category', 'brand', 'filter', 'clearance', 'deals', 'other'
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(campaign_id, link_url)
);
CREATE INDEX IF NOT EXISTS idx_kcl_campaign_id ON klaviyo_campaign_links(campaign_id);
CREATE INDEX IF NOT EXISTS idx_kcl_sent_at ON klaviyo_campaign_links(sent_at);

View File

@@ -0,0 +1,279 @@
/**
* Extract products featured in Klaviyo campaign emails and store in DB.
*
* - Fetches recent sent campaigns from Klaviyo API
* - Gets template HTML for each campaign message
* - Parses out product links (/shop/{id}) and other shop links
* - Inserts into klaviyo_campaign_products and klaviyo_campaign_links tables
*
* Usage: node scripts/poc-campaign-products.js [limit] [offset]
* limit: number of sent campaigns to process (default: 10)
* offset: number of sent campaigns to skip before processing (default: 0)
*
* Requires DB_* env vars (from inventory-server .env) and KLAVIYO_API_KEY.
*/
import fetch from 'node-fetch';
import pg from 'pg';
import dotenv from 'dotenv';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Load klaviyo .env for API key
dotenv.config({ path: path.resolve(__dirname, '../.env') });
// Also load the main inventory-server .env for DB credentials
const mainEnvPath = '/var/www/html/inventory/.env';
if (fs.existsSync(mainEnvPath)) {
dotenv.config({ path: mainEnvPath });
}
const API_KEY = process.env.KLAVIYO_API_KEY;
const REVISION = process.env.KLAVIYO_API_REVISION || '2026-01-15';
const BASE_URL = 'https://a.klaviyo.com/api';
if (!API_KEY) {
console.error('KLAVIYO_API_KEY not set in .env');
process.exit(1);
}
// ── Klaviyo API helpers ──────────────────────────────────────────────
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Klaviyo-API-Key ${API_KEY}`,
'revision': REVISION,
};
async function klaviyoGet(endpoint, params = {}) {
const url = new URL(`${BASE_URL}${endpoint}`);
for (const [k, v] of Object.entries(params)) {
url.searchParams.append(k, v);
}
return klaviyoFetch(url.toString());
}
async function klaviyoFetch(url) {
const res = await fetch(url, { headers });
if (!res.ok) {
const body = await res.text();
throw new Error(`Klaviyo ${res.status} on ${url}: ${body}`);
}
return res.json();
}
async function getRecentCampaigns(limit, offset = 0) {
const campaigns = [];
const messageMap = {};
let skipped = 0;
let data = await klaviyoGet('/campaigns', {
'filter': 'equals(messages.channel,"email")',
'sort': '-scheduled_at',
'include': 'campaign-messages',
});
while (true) {
for (const c of (data.data || [])) {
if (c.attributes?.status === 'Sent') {
if (skipped < offset) {
skipped++;
continue;
}
campaigns.push(c);
if (campaigns.length >= limit) break;
}
}
for (const inc of (data.included || [])) {
if (inc.type === 'campaign-message') {
messageMap[inc.id] = inc;
}
}
const nextUrl = data.links?.next;
if (campaigns.length >= limit || !nextUrl) break;
const progress = skipped < offset
? `Skipped ${skipped}/${offset}...`
: `Fetched ${campaigns.length}/${limit} sent campaigns, loading next page...`;
console.log(` ${progress}`);
await new Promise(r => setTimeout(r, 200));
data = await klaviyoFetch(nextUrl);
}
return { campaigns: campaigns.slice(0, limit), messageMap };
}
async function getTemplateHtml(messageId) {
const data = await klaviyoGet(`/campaign-messages/${messageId}/template`, {
'fields[template]': 'html,name',
});
return {
templateId: data.data?.id,
templateName: data.data?.attributes?.name,
html: data.data?.attributes?.html || '',
};
}
// ── HTML parsing ─────────────────────────────────────────────────────
function parseProductsFromHtml(html) {
const seen = new Set();
const products = [];
const linkRegex = /href="([^"]*acherryontop\.com\/shop\/(\d+))[^"]*"/gi;
let match;
while ((match = linkRegex.exec(html)) !== null) {
const productId = match[2];
if (!seen.has(productId)) {
seen.add(productId);
products.push({
siteProductId: productId,
url: match[1],
});
}
}
const categoryLinks = [];
const catRegex = /href="([^"]*acherryontop\.com\/shop\/[^"]+)"/gi;
while ((match = catRegex.exec(html)) !== null) {
const url = match[1];
if (/\/shop\/\d+$/.test(url)) continue;
if (!categoryLinks.includes(url)) categoryLinks.push(url);
}
return { products, categoryLinks };
}
function classifyLink(url) {
if (/\/shop\/(new|pre-order|backinstock)/.test(url)) return 'filter';
if (/\/shop\/company\//.test(url)) return 'brand';
if (/\/shop\/clearance/.test(url)) return 'clearance';
if (/\/shop\/daily_deals/.test(url)) return 'deals';
if (/\/shop\/category\//.test(url)) return 'category';
return 'other';
}
// ── Database ─────────────────────────────────────────────────────────
function createPool() {
return new pg.Pool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT || 5432,
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
});
}
async function insertProducts(pool, campaignId, campaignName, sentAt, products) {
if (products.length === 0) return 0;
let inserted = 0;
for (const p of products) {
try {
await pool.query(
`INSERT INTO klaviyo_campaign_products
(campaign_id, campaign_name, sent_at, pid, product_url)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (campaign_id, pid) DO NOTHING`,
[campaignId, campaignName, sentAt, parseInt(p.siteProductId), p.url]
);
inserted++;
} catch (err) {
console.error(` Error inserting product ${p.siteProductId}: ${err.message}`);
}
}
return inserted;
}
async function insertLinks(pool, campaignId, campaignName, sentAt, links) {
if (links.length === 0) return 0;
let inserted = 0;
for (const url of links) {
try {
await pool.query(
`INSERT INTO klaviyo_campaign_links
(campaign_id, campaign_name, sent_at, link_url, link_type)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (campaign_id, link_url) DO NOTHING`,
[campaignId, campaignName, sentAt, url, classifyLink(url)]
);
inserted++;
} catch (err) {
console.error(` Error inserting link: ${err.message}`);
}
}
return inserted;
}
// ── Main ─────────────────────────────────────────────────────────────
async function main() {
const limit = parseInt(process.argv[2]) || 10;
const offset = parseInt(process.argv[3]) || 0;
const pool = createPool();
try {
// Fetch campaigns
console.log(`Fetching up to ${limit} recent campaigns (offset: ${offset})...\n`);
const { campaigns, messageMap } = await getRecentCampaigns(limit, offset);
console.log(`Found ${campaigns.length} sent campaigns.\n`);
let totalProducts = 0;
let totalLinks = 0;
for (const campaign of campaigns) {
const name = campaign.attributes?.name || 'Unnamed';
const sentAt = campaign.attributes?.send_time;
console.log(`━━━ ${name} (${sentAt?.slice(0, 10) || 'no date'}) ━━━`);
const msgIds = (campaign.relationships?.['campaign-messages']?.data || [])
.map(r => r.id);
if (msgIds.length === 0) {
console.log(' No messages.\n');
continue;
}
for (const msgId of msgIds) {
const msg = messageMap[msgId];
const subject = msg?.attributes?.definition?.content?.subject;
if (subject) console.log(` Subject: ${subject}`);
try {
const template = await getTemplateHtml(msgId);
const { products, categoryLinks } = parseProductsFromHtml(template.html);
const pInserted = await insertProducts(pool, campaign.id, name, sentAt, products);
const lInserted = await insertLinks(pool, campaign.id, name, sentAt, categoryLinks);
console.log(` ${products.length} products (${pInserted} new), ${categoryLinks.length} links (${lInserted} new)`);
totalProducts += pInserted;
totalLinks += lInserted;
await new Promise(r => setTimeout(r, 200));
} catch (err) {
console.log(` Error: ${err.message}`);
}
}
console.log('');
}
console.log(`Done. Inserted ${totalProducts} product rows, ${totalLinks} link rows.`);
} finally {
await pool.end();
}
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,17 @@
-- Daily Deals schema for local PostgreSQL
-- Synced from production MySQL product_daily_deals + product_current_prices
CREATE TABLE IF NOT EXISTS product_daily_deals (
deal_id serial PRIMARY KEY,
deal_date date NOT NULL,
pid bigint NOT NULL,
price_id bigint NOT NULL,
-- Denormalized from product_current_prices so we don't need to sync that whole table
deal_price numeric(10,3),
created_at timestamptz DEFAULT NOW(),
CONSTRAINT fk_daily_deals_pid FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_daily_deals_date ON product_daily_deals(deal_date);
CREATE INDEX IF NOT EXISTS idx_daily_deals_pid ON product_daily_deals(pid);
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_deals_unique ON product_daily_deals(deal_date, pid);

View File

@@ -0,0 +1,234 @@
-- Custom PostgreSQL functions used by the metrics pipeline
-- These must exist in the database before running calculate-metrics-new.js
--
-- To install/update: psql -d inventory_db -f functions.sql
-- All functions use CREATE OR REPLACE so they are safe to re-run.
-- =============================================================================
-- safe_divide: Division helper that returns a default value instead of erroring
-- on NULL or zero denominators.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.safe_divide(
numerator numeric,
denominator numeric,
default_value numeric DEFAULT NULL::numeric
)
RETURNS numeric
LANGUAGE plpgsql
IMMUTABLE
AS $function$
BEGIN
IF denominator IS NULL OR denominator = 0 THEN
RETURN default_value;
ELSE
RETURN numerator / denominator;
END IF;
END;
$function$;
-- =============================================================================
-- std_numeric: Standardized rounding helper for consistent numeric precision.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.std_numeric(
value numeric,
precision_digits integer DEFAULT 2
)
RETURNS numeric
LANGUAGE plpgsql
IMMUTABLE
AS $function$
BEGIN
IF value IS NULL THEN
RETURN NULL;
ELSE
RETURN ROUND(value, precision_digits);
END IF;
END;
$function$;
-- =============================================================================
-- calculate_sales_velocity: Daily sales velocity adjusted for stockout days.
-- Ensures at least 14-day denominator for products with sales to avoid
-- inflated velocity from short windows.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.calculate_sales_velocity(
sales_30d integer,
stockout_days_30d integer
)
RETURNS numeric
LANGUAGE plpgsql
IMMUTABLE
AS $function$
BEGIN
RETURN sales_30d /
NULLIF(
GREATEST(
30.0 - stockout_days_30d,
CASE
WHEN sales_30d > 0 THEN 14.0 -- If we have sales, ensure at least 14 days denominator
ELSE 30.0 -- If no sales, use full period
END
),
0
);
END;
$function$;
-- =============================================================================
-- get_weighted_avg_cost: Weighted average cost from receivings up to a given date.
-- Uses all non-canceled receivings (no row limit) weighted by quantity.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_weighted_avg_cost(
p_pid bigint,
p_date date
)
RETURNS numeric
LANGUAGE plpgsql
STABLE
AS $function$
DECLARE
weighted_cost NUMERIC;
BEGIN
SELECT
CASE
WHEN SUM(qty_each) > 0 THEN SUM(cost_each * qty_each) / SUM(qty_each)
ELSE NULL
END INTO weighted_cost
FROM receivings
WHERE pid = p_pid
AND received_date <= p_date
AND status != 'canceled';
RETURN weighted_cost;
END;
$function$;
-- =============================================================================
-- classify_demand_pattern: Classifies demand based on average demand and
-- coefficient of variation (CV). Standard inventory classification:
-- zero: no demand
-- stable: CV <= 0.2 (predictable, easy to forecast)
-- variable: CV <= 0.5 (some variability, still forecastable)
-- sporadic: low volume + high CV (intermittent demand)
-- lumpy: high volume + high CV (unpredictable bursts)
-- =============================================================================
CREATE OR REPLACE FUNCTION public.classify_demand_pattern(
avg_demand numeric,
cv numeric
)
RETURNS character varying
LANGUAGE plpgsql
IMMUTABLE
AS $function$
BEGIN
IF avg_demand IS NULL OR cv IS NULL THEN
RETURN NULL;
ELSIF avg_demand = 0 THEN
RETURN 'zero';
ELSIF cv <= 0.2 THEN
RETURN 'stable';
ELSIF cv <= 0.5 THEN
RETURN 'variable';
ELSIF avg_demand < 1.0 THEN
RETURN 'sporadic';
ELSE
RETURN 'lumpy';
END IF;
END;
$function$;
-- =============================================================================
-- detect_seasonal_pattern: Detects seasonality by comparing monthly average
-- sales across the last 12 months. Uses coefficient of variation across months
-- and peak-to-average ratio to classify patterns.
--
-- Returns:
-- seasonal_pattern: 'none', 'moderate', or 'strong'
-- seasonality_index: peak month avg / overall avg * 100 (100 = no seasonality)
-- peak_season: name of peak month (e.g. 'January'), or NULL if none
-- =============================================================================
CREATE OR REPLACE FUNCTION public.detect_seasonal_pattern(p_pid bigint)
RETURNS TABLE(seasonal_pattern character varying, seasonality_index numeric, peak_season character varying)
LANGUAGE plpgsql
STABLE
AS $function$
DECLARE
v_monthly_cv NUMERIC;
v_max_month_avg NUMERIC;
v_overall_avg NUMERIC;
v_monthly_stddev NUMERIC;
v_peak_month_num INT;
v_data_months INT;
v_seasonality_index NUMERIC;
v_seasonal_pattern VARCHAR;
v_peak_season VARCHAR;
BEGIN
-- Gather monthly average sales and peak month in a single query
SELECT
COUNT(*),
AVG(month_avg),
STDDEV(month_avg),
MAX(month_avg),
(ARRAY_AGG(mo ORDER BY month_avg DESC))[1]::INT
INTO v_data_months, v_overall_avg, v_monthly_stddev, v_max_month_avg, v_peak_month_num
FROM (
SELECT EXTRACT(MONTH FROM snapshot_date) AS mo, AVG(units_sold) AS month_avg
FROM daily_product_snapshots
WHERE pid = p_pid AND snapshot_date >= CURRENT_DATE - INTERVAL '365 days'
GROUP BY EXTRACT(MONTH FROM snapshot_date)
) monthly;
-- Need at least 3 months of data for meaningful seasonality detection
IF v_data_months < 3 OR v_overall_avg IS NULL OR v_overall_avg = 0 THEN
RETURN QUERY SELECT 'none'::VARCHAR, 100::NUMERIC, NULL::VARCHAR;
RETURN;
END IF;
-- CV of monthly averages
v_monthly_cv := v_monthly_stddev / v_overall_avg;
-- Seasonality index: peak month avg / overall avg * 100
v_seasonality_index := ROUND((v_max_month_avg / v_overall_avg * 100)::NUMERIC, 2);
IF v_monthly_cv > 0.5 AND v_seasonality_index > 150 THEN
v_seasonal_pattern := 'strong';
v_peak_season := TRIM(TO_CHAR(TO_DATE(v_peak_month_num::TEXT, 'MM'), 'Month'));
ELSIF v_monthly_cv > 0.3 AND v_seasonality_index > 120 THEN
v_seasonal_pattern := 'moderate';
v_peak_season := TRIM(TO_CHAR(TO_DATE(v_peak_month_num::TEXT, 'MM'), 'Month'));
ELSE
v_seasonal_pattern := 'none';
v_peak_season := NULL;
v_seasonality_index := 100;
END IF;
RETURN QUERY SELECT v_seasonal_pattern, v_seasonality_index, v_peak_season;
END;
$function$;
-- =============================================================================
-- category_hierarchy: Materialized view providing a recursive category tree
-- with ancestor paths for efficient rollup queries.
--
-- Refresh after category changes: REFRESH MATERIALIZED VIEW category_hierarchy;
-- =============================================================================
-- DROP MATERIALIZED VIEW IF EXISTS category_hierarchy;
-- CREATE MATERIALIZED VIEW category_hierarchy AS
-- WITH RECURSIVE cat_tree AS (
-- SELECT cat_id, name, type, parent_id,
-- cat_id AS root_id, 0 AS level, ARRAY[cat_id] AS path
-- FROM categories
-- WHERE parent_id IS NULL
-- UNION ALL
-- SELECT c.cat_id, c.name, c.type, c.parent_id,
-- ct.root_id, ct.level + 1, ct.path || c.cat_id
-- FROM categories c
-- JOIN cat_tree ct ON c.parent_id = ct.cat_id
-- )
-- SELECT cat_id, name, type, parent_id, root_id, level, path,
-- (SELECT array_agg(unnest ORDER BY unnest DESC)
-- FROM unnest(cat_tree.path) unnest
-- WHERE unnest <> cat_tree.cat_id) AS ancestor_ids
-- FROM cat_tree;
--
-- CREATE UNIQUE INDEX ON category_hierarchy (cat_id);

View File

@@ -80,7 +80,6 @@ CREATE TABLE public.product_metrics (
current_price NUMERIC(10, 2),
current_regular_price NUMERIC(10, 2),
current_cost_price NUMERIC(10, 4), -- Increased precision for cost
current_landing_cost_price NUMERIC(10, 4), -- Increased precision for cost
current_stock INT NOT NULL DEFAULT 0,
current_stock_cost NUMERIC(14, 4) NOT NULL DEFAULT 0.00,
current_stock_retail NUMERIC(14, 4) NOT NULL DEFAULT 0.00,
@@ -156,9 +155,9 @@ CREATE TABLE public.product_metrics (
days_of_stock_closing_stock NUMERIC(10, 2), -- lead_time_closing_stock - days_of_stock_forecast_units
replenishment_needed_raw NUMERIC(10, 2), -- planning_period_forecast_units + config_safety_stock - current_stock - on_order_qty
replenishment_units INT, -- CEILING(GREATEST(0, replenishment_needed_raw))
replenishment_cost NUMERIC(14, 4), -- replenishment_units * COALESCE(current_landing_cost_price, current_cost_price)
replenishment_cost NUMERIC(14, 4), -- replenishment_units * current_cost_price
replenishment_retail NUMERIC(14, 4), -- replenishment_units * current_price
replenishment_profit NUMERIC(14, 4), -- replenishment_units * (current_price - COALESCE(current_landing_cost_price, current_cost_price))
replenishment_profit NUMERIC(14, 4), -- replenishment_units * (current_price - current_cost_price)
to_order_units INT, -- Apply MOQ/UOM logic to replenishment_units
forecast_lost_sales_units NUMERIC(10, 2), -- GREATEST(0, -lead_time_closing_stock)
forecast_lost_revenue NUMERIC(14, 4), -- forecast_lost_sales_units * current_price
@@ -167,7 +166,7 @@ CREATE TABLE public.product_metrics (
sells_out_in_days NUMERIC(10, 1), -- (current_stock + on_order_qty) / sales_velocity_daily
replenish_date DATE, -- Calc based on when stock hits safety stock minus lead time
overstocked_units INT, -- GREATEST(0, current_stock - config_safety_stock - planning_period_forecast_units)
overstocked_cost NUMERIC(14, 4), -- overstocked_units * COALESCE(current_landing_cost_price, current_cost_price)
overstocked_cost NUMERIC(14, 4), -- overstocked_units * current_cost_price
overstocked_retail NUMERIC(14, 4), -- overstocked_units * current_price
is_old_stock BOOLEAN, -- Based on age, last sold, last received, on_order status

View File

@@ -0,0 +1,20 @@
-- Migration: Add date_online and shop_score columns to products table
-- These fields are imported from production to improve newsletter recommendation accuracy:
-- date_online = products.date_ol in production (date product went live on the shop)
-- shop_score = products.score in production (sales-based popularity score)
--
-- After running this migration, do a full (non-incremental) import to backfill:
-- INCREMENTAL_UPDATE=false node scripts/import-from-prod.js
-- Add date_online column (production: products.date_ol)
ALTER TABLE products ADD COLUMN IF NOT EXISTS date_online TIMESTAMP WITH TIME ZONE;
-- Add shop_score column (production: products.score)
-- Using NUMERIC(10,2) to preserve the decimal precision from production
ALTER TABLE products ADD COLUMN IF NOT EXISTS shop_score NUMERIC(10, 2) DEFAULT 0;
-- If shop_score was previously created as INTEGER, convert it
ALTER TABLE products ALTER COLUMN shop_score TYPE NUMERIC(10, 2);
-- Index on date_online for the newsletter "new products" filter
CREATE INDEX IF NOT EXISTS idx_products_date_online ON products(date_online);

View File

@@ -21,6 +21,7 @@ CREATE TABLE products (
description TEXT,
sku TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE,
date_online TIMESTAMP WITH TIME ZONE,
first_received TIMESTAMP WITH TIME ZONE,
stock_quantity INTEGER DEFAULT 0,
preorder_count INTEGER DEFAULT 0,
@@ -28,7 +29,6 @@ CREATE TABLE products (
price NUMERIC(14, 4) NOT NULL,
regular_price NUMERIC(14, 4) NOT NULL,
cost_price NUMERIC(14, 4),
landing_cost_price NUMERIC(14, 4),
barcode TEXT,
harmonized_tariff_code TEXT,
updated_at TIMESTAMP WITH TIME ZONE,
@@ -63,6 +63,7 @@ CREATE TABLE products (
baskets INTEGER DEFAULT 0,
notifies INTEGER DEFAULT 0,
date_last_sold DATE,
shop_score NUMERIC(10, 2) DEFAULT 0,
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (pid)
);

View File

@@ -11,6 +11,7 @@ const RUN_PERIODIC_METRICS = true;
const RUN_BRAND_METRICS = true;
const RUN_VENDOR_METRICS = true;
const RUN_CATEGORY_METRICS = true;
const RUN_LIFECYCLE_FORECASTS = true;
// Maximum execution time for the entire sequence (e.g., 90 minutes)
const MAX_EXECUTION_TIME_TOTAL = 90 * 60 * 1000;
@@ -592,6 +593,13 @@ async function runAllCalculations() {
historyType: 'product_metrics',
statusModule: 'product_metrics'
},
{
run: RUN_LIFECYCLE_FORECASTS,
name: 'Lifecycle Forecast Update',
sqlFile: 'metrics-new/update_lifecycle_forecasts.sql',
historyType: 'lifecycle_forecasts',
statusModule: 'lifecycle_forecasts'
},
{
run: RUN_PERIODIC_METRICS,
name: 'Periodic Metrics Update',

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* Forecast Pipeline Orchestrator
*
* Spawns the Python forecast engine with database credentials from the
* environment. Can be run manually, via cron, or integrated into the
* existing metrics pipeline.
*
* Usage:
* node run_forecast.js
*
* Environment:
* Reads DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT from
* /var/www/html/inventory/.env (or current process env).
*/
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
// Load .env file if it exists (production path)
const envPaths = [
'/var/www/html/inventory/.env',
path.join(__dirname, '../../.env'),
];
for (const envPath of envPaths) {
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8');
for (const line of envContent.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex);
const value = trimmed.slice(eqIndex + 1);
if (!process.env[key]) {
process.env[key] = value;
}
}
console.log(`Loaded env from ${envPath}`);
break;
}
}
// Verify required env vars
const required = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
const missing = required.filter(k => !process.env[k]);
if (missing.length > 0) {
console.error(`Missing required environment variables: ${missing.join(', ')}`);
process.exit(1);
}
const SCRIPT_DIR = __dirname;
const PYTHON_SCRIPT = path.join(SCRIPT_DIR, 'forecast_engine.py');
const VENV_DIR = path.join(SCRIPT_DIR, 'venv');
const REQUIREMENTS = path.join(SCRIPT_DIR, 'requirements.txt');
// Determine python binary (prefer venv if it exists)
function getPythonBin() {
const venvPython = path.join(VENV_DIR, 'bin', 'python');
if (fs.existsSync(venvPython)) return venvPython;
// Fall back to system python
return 'python3';
}
// Ensure venv and dependencies are installed
async function ensureDependencies() {
if (!fs.existsSync(path.join(VENV_DIR, 'bin', 'python'))) {
console.log('Creating virtual environment...');
await runCommand('python3', ['-m', 'venv', VENV_DIR]);
}
// Always run pip install — idempotent, fast when packages already present
console.log('Checking dependencies...');
const python = path.join(VENV_DIR, 'bin', 'python');
await runCommand(python, ['-m', 'pip', 'install', '--quiet', '-r', REQUIREMENTS]);
}
function runCommand(cmd, args, options = {}) {
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, {
stdio: 'inherit',
...options,
});
proc.on('close', code => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited with code ${code}`));
});
proc.on('error', reject);
});
}
async function main() {
const startTime = Date.now();
console.log('='.repeat(60));
console.log(`Forecast Pipeline - ${new Date().toISOString()}`);
console.log('='.repeat(60));
try {
await ensureDependencies();
const pythonBin = getPythonBin();
console.log(`Using Python: ${pythonBin}`);
console.log(`Running: ${PYTHON_SCRIPT}`);
console.log('');
await runCommand(pythonBin, [PYTHON_SCRIPT], {
env: {
...process.env,
PYTHONUNBUFFERED: '1', // Real-time output
},
});
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
console.log('');
console.log('='.repeat(60));
console.log(`Forecast pipeline completed in ${duration}s`);
console.log('='.repeat(60));
} catch (err) {
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
console.error(`Forecast pipeline FAILED after ${duration}s:`, err.message);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,51 @@
-- Forecasting Pipeline Tables
-- Run once to create the schema. Safe to re-run (IF NOT EXISTS).
-- Precomputed reference decay curves per brand (or brand x category at any hierarchy level)
CREATE TABLE IF NOT EXISTS brand_lifecycle_curves (
id SERIAL PRIMARY KEY,
brand TEXT NOT NULL,
root_category TEXT, -- NULL = brand-level fallback curve, else category name
cat_id BIGINT, -- NULL = brand-only; else category_hierarchy.cat_id for precise matching
category_level SMALLINT, -- NULL = brand-only; 0-3 = hierarchy depth
amplitude NUMERIC(10,4), -- A in: sales(t) = A * exp(-λt) + C
decay_rate NUMERIC(10,6), -- λ (higher = faster decay)
baseline NUMERIC(10,4), -- C (long-tail steady-state daily sales)
r_squared NUMERIC(6,4), -- goodness of fit
sample_size INT, -- number of products that informed this curve
median_first_week_sales NUMERIC(10,2), -- for scaling new launches
median_preorder_sales NUMERIC(10,2), -- for scaling pre-order products
median_preorder_days NUMERIC(10,2), -- median pre-order accumulation window (days)
computed_at TIMESTAMP DEFAULT NOW(),
UNIQUE(brand, cat_id)
);
-- Per-product daily forecasts (next 90 days, regenerated each run)
CREATE TABLE IF NOT EXISTS product_forecasts (
pid BIGINT NOT NULL,
forecast_date DATE NOT NULL,
forecast_units NUMERIC(10,2),
forecast_revenue NUMERIC(14,4),
lifecycle_phase TEXT, -- preorder, launch, decay, mature, slow_mover, dormant
forecast_method TEXT, -- lifecycle_curve, exp_smoothing, velocity, zero
confidence_lower NUMERIC(10,2),
confidence_upper NUMERIC(10,2),
generated_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (pid, forecast_date)
);
CREATE INDEX IF NOT EXISTS idx_pf_date ON product_forecasts(forecast_date);
CREATE INDEX IF NOT EXISTS idx_pf_phase ON product_forecasts(lifecycle_phase);
-- Forecast run history (for monitoring)
CREATE TABLE IF NOT EXISTS forecast_runs (
id SERIAL PRIMARY KEY,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP,
status TEXT DEFAULT 'running', -- running, completed, failed
products_forecast INT,
phase_counts JSONB, -- {"launch": 50, "decay": 200, ...}
curve_count INT, -- brand curves computed
error_message TEXT,
duration_seconds NUMERIC(10,2)
);

View File

@@ -6,6 +6,8 @@ const importCategories = require('./import/categories');
const { importProducts } = require('./import/products');
const importOrders = require('./import/orders');
const importPurchaseOrders = require('./import/purchase-orders');
const importDailyDeals = require('./import/daily-deals');
const importStockSnapshots = require('./import/stock-snapshots');
dotenv.config({ path: path.join(__dirname, "../.env") });
@@ -14,6 +16,8 @@ const IMPORT_CATEGORIES = true;
const IMPORT_PRODUCTS = true;
const IMPORT_ORDERS = true;
const IMPORT_PURCHASE_ORDERS = true;
const IMPORT_DAILY_DEALS = true;
const IMPORT_STOCK_SNAPSHOTS = true;
// Add flag for incremental updates
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
@@ -36,7 +40,7 @@ const sshConfig = {
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306,
timezone: '-05:00', // Production DB always stores times in EST (UTC-5) regardless of DST
timezone: '-05:00', // mysql2 driver timezone — corrected at runtime via adjustDateForMySQL() in utils.js
},
localDbConfig: {
// PostgreSQL config for local
@@ -78,7 +82,9 @@ async function main() {
IMPORT_CATEGORIES,
IMPORT_PRODUCTS,
IMPORT_ORDERS,
IMPORT_PURCHASE_ORDERS
IMPORT_PURCHASE_ORDERS,
IMPORT_DAILY_DEALS,
IMPORT_STOCK_SNAPSHOTS
].filter(Boolean).length;
try {
@@ -126,10 +132,12 @@ async function main() {
'categories_enabled', $2::boolean,
'products_enabled', $3::boolean,
'orders_enabled', $4::boolean,
'purchase_orders_enabled', $5::boolean
'purchase_orders_enabled', $5::boolean,
'daily_deals_enabled', $6::boolean,
'stock_snapshots_enabled', $7::boolean
)
) RETURNING id
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS]);
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS, IMPORT_DAILY_DEALS, IMPORT_STOCK_SNAPSHOTS]);
importHistoryId = historyResult.rows[0].id;
} catch (error) {
console.error("Error creating import history record:", error);
@@ -146,7 +154,9 @@ async function main() {
categories: null,
products: null,
orders: null,
purchaseOrders: null
purchaseOrders: null,
dailyDeals: null,
stockSnapshots: null
};
let totalRecordsAdded = 0;
@@ -224,6 +234,61 @@ async function main() {
}
}
if (IMPORT_DAILY_DEALS) {
try {
const stepStart = Date.now();
results.dailyDeals = await importDailyDeals(prodConnection, localConnection);
stepTimings.dailyDeals = Math.round((Date.now() - stepStart) / 1000);
if (isImportCancelled) throw new Error("Import cancelled");
completedSteps++;
console.log('Daily deals import result:', results.dailyDeals);
if (results.dailyDeals?.status === 'error') {
console.error('Daily deals import had an error:', results.dailyDeals.error);
} else {
totalRecordsAdded += parseInt(results.dailyDeals?.recordsAdded || 0);
totalRecordsUpdated += parseInt(results.dailyDeals?.recordsUpdated || 0);
totalRecordsDeleted += parseInt(results.dailyDeals?.recordsDeleted || 0);
}
} catch (error) {
console.error('Error during daily deals import:', error);
results.dailyDeals = {
status: 'error',
error: error.message,
recordsAdded: 0,
recordsUpdated: 0
};
}
}
if (IMPORT_STOCK_SNAPSHOTS) {
try {
const stepStart = Date.now();
results.stockSnapshots = await importStockSnapshots(prodConnection, localConnection, INCREMENTAL_UPDATE);
stepTimings.stockSnapshots = Math.round((Date.now() - stepStart) / 1000);
if (isImportCancelled) throw new Error("Import cancelled");
completedSteps++;
console.log('Stock snapshots import result:', results.stockSnapshots);
if (results.stockSnapshots?.status === 'error') {
console.error('Stock snapshots import had an error:', results.stockSnapshots.error);
} else {
totalRecordsAdded += parseInt(results.stockSnapshots?.recordsAdded || 0);
totalRecordsUpdated += parseInt(results.stockSnapshots?.recordsUpdated || 0);
}
} catch (error) {
console.error('Error during stock snapshots import:', error);
results.stockSnapshots = {
status: 'error',
error: error.message,
recordsAdded: 0,
recordsUpdated: 0
};
}
}
const endTime = Date.now();
const totalElapsedSeconds = Math.round((endTime - startTime) / 1000);
@@ -241,15 +306,19 @@ async function main() {
'products_enabled', $5::boolean,
'orders_enabled', $6::boolean,
'purchase_orders_enabled', $7::boolean,
'categories_result', COALESCE($8::jsonb, 'null'::jsonb),
'products_result', COALESCE($9::jsonb, 'null'::jsonb),
'orders_result', COALESCE($10::jsonb, 'null'::jsonb),
'purchase_orders_result', COALESCE($11::jsonb, 'null'::jsonb),
'total_deleted', $12::integer,
'total_skipped', $13::integer,
'step_timings', $14::jsonb
'daily_deals_enabled', $8::boolean,
'categories_result', COALESCE($9::jsonb, 'null'::jsonb),
'products_result', COALESCE($10::jsonb, 'null'::jsonb),
'orders_result', COALESCE($11::jsonb, 'null'::jsonb),
'purchase_orders_result', COALESCE($12::jsonb, 'null'::jsonb),
'daily_deals_result', COALESCE($13::jsonb, 'null'::jsonb),
'stock_snapshots_enabled', $14::boolean,
'stock_snapshots_result', COALESCE($15::jsonb, 'null'::jsonb),
'total_deleted', $16::integer,
'total_skipped', $17::integer,
'step_timings', $18::jsonb
)
WHERE id = $15
WHERE id = $19
`, [
totalElapsedSeconds,
parseInt(totalRecordsAdded),
@@ -258,10 +327,14 @@ async function main() {
IMPORT_PRODUCTS,
IMPORT_ORDERS,
IMPORT_PURCHASE_ORDERS,
IMPORT_DAILY_DEALS,
JSON.stringify(results.categories),
JSON.stringify(results.products),
JSON.stringify(results.orders),
JSON.stringify(results.purchaseOrders),
JSON.stringify(results.dailyDeals),
IMPORT_STOCK_SNAPSHOTS,
JSON.stringify(results.stockSnapshots),
totalRecordsDeleted,
totalRecordsSkipped,
JSON.stringify(stepTimings),

View File

@@ -0,0 +1,167 @@
const { outputProgress, formatElapsedTime } = require('../metrics-new/utils/progress');
/**
* Import daily deals from production MySQL to local PostgreSQL.
*
* Production has two tables:
* - product_daily_deals (deal_id, deal_date, pid, price_id)
* - product_current_prices (price_id, pid, price_each, active, ...)
*
* We join them in the prod query to denormalize the deal price, avoiding
* the need to sync the full product_current_prices table.
*
* On each sync:
* 1. Fetch deals from the last 7 days (plus today) from production
* 2. Upsert into local table
* 3. Hard delete local deals older than 7 days past their deal_date
*/
async function importDailyDeals(prodConnection, localConnection) {
outputProgress({
operation: "Starting daily deals import",
status: "running",
});
const startTime = Date.now();
try {
await localConnection.query('BEGIN');
// Fetch recent daily deals from production (MySQL 5.7, no CTEs)
// Join product_current_prices to get the actual deal price
// Only grab last 7 days + today + tomorrow (for pre-scheduled deals)
const [deals] = await prodConnection.query(`
SELECT
pdd.deal_id,
pdd.deal_date,
pdd.pid,
pdd.price_id,
pcp.price_each as deal_price
FROM product_daily_deals pdd
LEFT JOIN product_current_prices pcp ON pcp.price_id = pdd.price_id
WHERE pdd.deal_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
AND pdd.deal_date <= DATE_ADD(CURDATE(), INTERVAL 1 DAY)
ORDER BY pdd.deal_date DESC, pdd.pid
`);
outputProgress({
status: "running",
operation: "Daily deals import",
message: `Fetched ${deals.length} deals from production`,
elapsed: formatElapsedTime(startTime),
});
let totalInserted = 0;
let totalUpdated = 0;
if (deals.length > 0) {
// Batch upsert — filter to only PIDs that exist locally
const pids = [...new Set(deals.map(d => d.pid))];
const existingResult = await localConnection.query(
`SELECT pid FROM products WHERE pid = ANY($1)`,
[pids]
);
const existingPids = new Set(
(Array.isArray(existingResult) ? existingResult[0] : existingResult)
.rows.map(r => Number(r.pid))
);
const validDeals = deals.filter(d => existingPids.has(Number(d.pid)));
if (validDeals.length > 0) {
// Build batch upsert
const values = validDeals.flatMap(d => [
d.deal_date,
d.pid,
d.price_id,
d.deal_price ?? null,
]);
const placeholders = validDeals
.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`)
.join(',');
const upsertQuery = `
WITH upserted AS (
INSERT INTO product_daily_deals (deal_date, pid, price_id, deal_price)
VALUES ${placeholders}
ON CONFLICT (deal_date, pid) DO UPDATE SET
price_id = EXCLUDED.price_id,
deal_price = EXCLUDED.deal_price
WHERE
product_daily_deals.price_id IS DISTINCT FROM EXCLUDED.price_id OR
product_daily_deals.deal_price IS DISTINCT FROM EXCLUDED.deal_price
RETURNING
CASE WHEN xmax = 0 THEN true ELSE false END as is_insert
)
SELECT
COUNT(*) FILTER (WHERE is_insert) as inserted,
COUNT(*) FILTER (WHERE NOT is_insert) as updated
FROM upserted
`;
const result = await localConnection.query(upsertQuery, values);
const queryResult = Array.isArray(result) ? result[0] : result;
totalInserted = parseInt(queryResult.rows[0].inserted) || 0;
totalUpdated = parseInt(queryResult.rows[0].updated) || 0;
}
const skipped = deals.length - validDeals.length;
if (skipped > 0) {
console.log(`Skipped ${skipped} deals (PIDs not in local products table)`);
}
}
// Hard delete deals older than 7 days past their deal_date
const deleteResult = await localConnection.query(`
DELETE FROM product_daily_deals
WHERE deal_date < CURRENT_DATE - INTERVAL '7 days'
`);
const deletedCount = deleteResult.rowCount ??
(Array.isArray(deleteResult) ? deleteResult[0]?.rowCount : 0) ?? 0;
// Update sync status
await localConnection.query(`
INSERT INTO sync_status (table_name, last_sync_timestamp)
VALUES ('product_daily_deals', NOW())
ON CONFLICT (table_name) DO UPDATE SET
last_sync_timestamp = NOW()
`);
await localConnection.query('COMMIT');
outputProgress({
status: "complete",
operation: "Daily deals import completed",
message: `Inserted ${totalInserted}, updated ${totalUpdated}, deleted ${deletedCount} expired`,
current: totalInserted + totalUpdated,
total: totalInserted + totalUpdated,
duration: formatElapsedTime(startTime),
});
return {
status: "complete",
recordsAdded: totalInserted,
recordsUpdated: totalUpdated,
recordsDeleted: deletedCount,
totalRecords: totalInserted + totalUpdated,
};
} catch (error) {
console.error("Error importing daily deals:", error);
try {
await localConnection.query('ROLLBACK');
} catch (rollbackError) {
console.error("Error during rollback:", rollbackError);
}
outputProgress({
status: "error",
operation: "Daily deals import failed",
error: error.message,
});
throw error;
}
}
module.exports = importDailyDeals;

View File

@@ -17,6 +17,33 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
const startTime = Date.now();
const skippedOrders = new Set();
const missingProducts = new Set();
// Map order status codes to text values (consistent with PO status mapping in purchase-orders.js)
const orderStatusMap = {
0: 'created',
10: 'unfinished',
15: 'canceled',
16: 'combined',
20: 'placed',
22: 'placed_incomplete',
30: 'canceled',
40: 'awaiting_payment',
50: 'awaiting_products',
55: 'shipping_later',
56: 'shipping_together',
60: 'ready',
61: 'flagged',
62: 'fix_before_pick',
65: 'manual_picking',
70: 'in_pt',
80: 'picked',
90: 'awaiting_shipment',
91: 'remote_wait',
92: 'awaiting_pickup',
93: 'fix_before_ship',
95: 'shipped_confirmed',
100: 'shipped'
};
let recordsAdded = 0;
let recordsUpdated = 0;
let processedCount = 0;
@@ -31,8 +58,12 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'orders'"
);
const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01';
// Adjust for mysql2 driver timezone vs MySQL server timezone mismatch
const mysqlSyncTime = prodConnection.adjustDateForMySQL
? prodConnection.adjustDateForMySQL(lastSyncTime)
: lastSyncTime;
console.log('Orders: Using last sync time:', lastSyncTime);
console.log('Orders: Using last sync time:', lastSyncTime, '(adjusted:', mysqlSyncTime, ')');
// First get count of order items - Keep MySQL compatible for production
const [[{ total }]] = await prodConnection.query(`
@@ -46,11 +77,6 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
AND (
o.stamp > ?
OR oi.stamp > ?
OR EXISTS (
SELECT 1 FROM order_discount_items odi
WHERE odi.order_id = o.order_id
AND odi.pid = oi.prod_pid
)
OR EXISTS (
SELECT 1 FROM order_tax_info oti
JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id
@@ -60,7 +86,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
)
)
` : ''}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
totalOrderItems = total;
console.log('Orders: Found changes:', totalOrderItems);
@@ -85,11 +111,6 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
AND (
o.stamp > ?
OR oi.stamp > ?
OR EXISTS (
SELECT 1 FROM order_discount_items odi
WHERE odi.order_id = o.order_id
AND odi.pid = oi.prod_pid
)
OR EXISTS (
SELECT 1 FROM order_tax_info oti
JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id
@@ -99,7 +120,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
)
)
` : ''}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
console.log('Orders: Found', orderItems.length, 'order items to process');
@@ -284,7 +305,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
new Date(order.date), // Convert to TIMESTAMP WITH TIME ZONE
order.customer,
toTitleCase(order.customer_name) || '',
order.status.toString(), // Convert status to TEXT
orderStatusMap[order.status] || order.status.toString(), // Map numeric status to text
order.canceled,
order.summary_discount || 0,
order.summary_subtotal || 0,
@@ -513,11 +534,12 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
}
};
// Process all data types SEQUENTIALLY for each batch - not in parallel
// Process all data types for each batch
// Note: these run sequentially because they share a single PG connection
// and each manages its own transaction
for (let i = 0; i < orderIds.length; i += METADATA_BATCH_SIZE) {
const batchIds = orderIds.slice(i, i + METADATA_BATCH_SIZE);
// Run these sequentially instead of in parallel to avoid transaction conflicts
await processMetadataBatch(batchIds);
await processDiscountsBatch(batchIds);
await processTaxesBatch(batchIds);
@@ -536,17 +558,37 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
});
}
// Pre-check all products at once
// Pre-check all products and preload cost_price into a temp table
// This avoids joining public.products in every sub-batch query (was causing 2x slowdown)
const allOrderPids = [...new Set(orderItems.map(item => item.prod_pid))];
console.log('Orders: Checking', allOrderPids.length, 'unique products');
const [existingProducts] = allOrderPids.length > 0 ? await localConnection.query(
"SELECT pid FROM products WHERE pid = ANY($1::bigint[])",
"SELECT pid, cost_price FROM products WHERE pid = ANY($1::bigint[])",
[allOrderPids]
) : [[]];
) : [{ rows: [] }];
const existingPids = new Set(existingProducts.rows.map(p => p.pid));
// Create temp table with product cost_price for fast lookup in sub-batch queries
await localConnection.query(`
DROP TABLE IF EXISTS temp_product_costs;
CREATE TEMP TABLE temp_product_costs (
pid BIGINT PRIMARY KEY,
cost_price NUMERIC(14, 4)
)
`);
if (existingProducts.rows.length > 0) {
const costPids = existingProducts.rows.filter(p => p.cost_price != null).map(p => p.pid);
const costPrices = existingProducts.rows.filter(p => p.cost_price != null).map(p => p.cost_price);
if (costPids.length > 0) {
await localConnection.query(`
INSERT INTO temp_product_costs (pid, cost_price)
SELECT * FROM UNNEST($1::bigint[], $2::numeric[])
`, [costPids, costPrices]);
}
}
// Process in smaller batches
for (let i = 0; i < orderIds.length; i += 2000) { // Increased from 1000 to 2000
const batchIds = orderIds.slice(i, i + 2000);
@@ -570,14 +612,15 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
ELSE 0
END) as promo_discount_sum,
COALESCE(ot.tax, 0) as total_tax,
COALESCE(oc.costeach, oi.price * 0.5) as costeach
COALESCE(oc.costeach, pc.cost_price, oi.price * 0.5) as costeach
FROM temp_order_items oi
LEFT JOIN temp_item_discounts id ON oi.order_id = id.order_id AND oi.pid = id.pid
LEFT JOIN temp_main_discounts md ON id.order_id = md.order_id AND id.discount_id = md.discount_id
LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
LEFT JOIN temp_order_costs oc ON oi.order_id = oc.order_id AND oi.pid = oc.pid
LEFT JOIN temp_product_costs pc ON oi.pid = pc.pid
WHERE oi.order_id = ANY($1)
GROUP BY oi.order_id, oi.pid, ot.tax, oc.costeach
GROUP BY oi.order_id, oi.pid, ot.tax, oc.costeach, pc.cost_price
)
SELECT
oi.order_id as order_number,
@@ -587,17 +630,14 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
oi.price,
oi.quantity,
(
-- Part 1: Sale Savings for the Line
(oi.base_discount * oi.quantity)
+
-- Part 2: Prorated Points Discount (if applicable)
-- Prorated Points Discount (e.g. loyalty points applied at order level)
CASE
WHEN om.summary_discount_subtotal > 0 AND om.summary_subtotal > 0 THEN
COALESCE(ROUND((om.summary_discount_subtotal * (oi.price * oi.quantity)) / NULLIF(om.summary_subtotal, 0), 4), 0)
ELSE 0
END
+
-- Part 3: Specific Item-Level Discount (only if parent discount affected subtotal)
-- Specific Item-Level Promo Discount (coupon codes, etc.)
COALESCE(ot.promo_discount_sum, 0)
)::NUMERIC(14, 4) as discount,
COALESCE(ot.total_tax, 0)::NUMERIC(14, 4) as tax,
@@ -607,10 +647,11 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
om.customer_name,
om.status,
om.canceled,
COALESCE(ot.costeach, oi.price * 0.5)::NUMERIC(14, 4) as costeach
COALESCE(ot.costeach, pc.cost_price, oi.price * 0.5)::NUMERIC(14, 4) as costeach
FROM temp_order_items oi
JOIN temp_order_meta om ON oi.order_id = om.order_id
LEFT JOIN order_totals ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
LEFT JOIN temp_product_costs pc ON oi.pid = pc.pid
WHERE oi.order_id = ANY($1)
ORDER BY oi.order_id, oi.pid
`, [subBatchIds]);
@@ -654,7 +695,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
o.shipping,
o.customer,
o.customer_name,
o.status.toString(), // Convert status to TEXT
o.status, // Already mapped to text via orderStatusMap
o.canceled,
o.costeach
]);
@@ -744,6 +785,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
DROP TABLE IF EXISTS temp_order_costs;
DROP TABLE IF EXISTS temp_main_discounts;
DROP TABLE IF EXISTS temp_item_discounts;
DROP TABLE IF EXISTS temp_product_costs;
`);
// Commit final transaction

View File

@@ -75,8 +75,8 @@ async function setupTemporaryTables(connection) {
artist TEXT,
categories TEXT,
created_at TIMESTAMP WITH TIME ZONE,
date_online TIMESTAMP WITH TIME ZONE,
first_received TIMESTAMP WITH TIME ZONE,
landing_cost_price NUMERIC(14, 4),
barcode TEXT,
harmonized_tariff_code TEXT,
updated_at TIMESTAMP WITH TIME ZONE,
@@ -98,6 +98,7 @@ async function setupTemporaryTables(connection) {
baskets INTEGER,
notifies INTEGER,
date_last_sold TIMESTAMP WITH TIME ZONE,
shop_score NUMERIC(10, 2) DEFAULT 0,
primary_iid INTEGER,
image TEXT,
image_175 TEXT,
@@ -137,6 +138,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
p.notes AS description,
p.itemnumber AS sku,
p.date_created,
p.date_ol,
p.datein AS first_received,
p.location,
p.upc AS barcode,
@@ -169,7 +171,6 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
)
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
END AS cost_price,
NULL as landing_cost_price,
s.companyname AS vendor,
CASE
WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber
@@ -199,6 +200,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
JOIN _order o ON oi.order_id = o.order_id
WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold,
pls.date_sold as date_last_sold,
COALESCE(p.score, 0) as shop_score,
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
GROUP_CONCAT(DISTINCT CASE
WHEN pc.cat_id IS NOT NULL
@@ -238,8 +240,8 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
const batch = prodData.slice(i, i + BATCH_SIZE);
const placeholders = batch.map((_, idx) => {
const base = idx * 48; // 48 columns
return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
const base = idx * 49; // 49 columns
return `(${Array.from({ length: 49 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
}).join(',');
const values = batch.flatMap(row => {
@@ -264,8 +266,8 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
row.artist,
row.category_ids,
validateDate(row.date_created),
validateDate(row.date_ol),
validateDate(row.first_received),
row.landing_cost_price,
row.barcode,
row.harmonized_tariff_code,
validateDate(row.updated_at),
@@ -287,6 +289,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
row.baskets,
row.notifies,
validateDate(row.date_last_sold),
Number(row.shop_score) || 0,
row.primary_iid,
imageUrls.image,
imageUrls.image_175,
@@ -301,11 +304,11 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
INSERT INTO products (
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
brand, line, subline, artist, categories, created_at, first_received,
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible,
brand, line, subline, artist, categories, created_at, date_online, first_received,
barcode, harmonized_tariff_code, updated_at, visible,
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
weight, length, width, height, country_of_origin, location, total_sold,
baskets, notifies, date_last_sold, primary_iid, image, image_175, image_full, options, tags
baskets, notifies, date_last_sold, shop_score, primary_iid, image, image_175, image_full, options, tags
)
VALUES ${placeholders}
ON CONFLICT (pid) DO NOTHING
@@ -343,6 +346,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
p.notes AS description,
p.itemnumber AS sku,
p.date_created,
p.date_ol,
p.datein AS first_received,
p.location,
p.upc AS barcode,
@@ -375,7 +379,6 @@ async function materializeCalculations(prodConnection, localConnection, incremen
)
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
END AS cost_price,
NULL as landing_cost_price,
s.companyname AS vendor,
CASE
WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber
@@ -405,6 +408,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
JOIN _order o ON oi.order_id = o.order_id
WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold,
pls.date_sold as date_last_sold,
COALESCE(p.score, 0) as shop_score,
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
GROUP_CONCAT(DISTINCT CASE
WHEN pc.cat_id IS NOT NULL
@@ -427,7 +431,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id
LEFT JOIN product_last_sold pls ON p.pid = pls.pid
WHERE ${incrementalUpdate ? `
p.date_created > ? OR
p.date_created >= DATE(?) OR
p.stamp > ? OR
ci.stamp > ? OR
pcp.date_deactive > ? OR
@@ -449,8 +453,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen
await withRetry(async () => {
const placeholders = batch.map((_, idx) => {
const base = idx * 48; // 48 columns
return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
const base = idx * 49; // 49 columns
return `(${Array.from({ length: 49 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
}).join(',');
const values = batch.flatMap(row => {
@@ -475,8 +479,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen
row.artist,
row.category_ids,
validateDate(row.date_created),
validateDate(row.date_ol),
validateDate(row.first_received),
row.landing_cost_price,
row.barcode,
row.harmonized_tariff_code,
validateDate(row.updated_at),
@@ -498,6 +502,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
row.baskets,
row.notifies,
validateDate(row.date_last_sold),
Number(row.shop_score) || 0,
row.primary_iid,
imageUrls.image,
imageUrls.image_175,
@@ -511,11 +516,11 @@ async function materializeCalculations(prodConnection, localConnection, incremen
INSERT INTO temp_products (
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
brand, line, subline, artist, categories, created_at, first_received,
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible,
brand, line, subline, artist, categories, created_at, date_online, first_received,
barcode, harmonized_tariff_code, updated_at, visible,
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
weight, length, width, height, country_of_origin, location, total_sold,
baskets, notifies, date_last_sold, primary_iid, image, image_175, image_full, options, tags
baskets, notifies, date_last_sold, shop_score, primary_iid, image, image_175, image_full, options, tags
) VALUES ${placeholders}
ON CONFLICT (pid) DO UPDATE SET
title = EXCLUDED.title,
@@ -535,8 +540,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen
subline = EXCLUDED.subline,
artist = EXCLUDED.artist,
created_at = EXCLUDED.created_at,
date_online = EXCLUDED.date_online,
first_received = EXCLUDED.first_received,
landing_cost_price = EXCLUDED.landing_cost_price,
barcode = EXCLUDED.barcode,
harmonized_tariff_code = EXCLUDED.harmonized_tariff_code,
updated_at = EXCLUDED.updated_at,
@@ -558,6 +563,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
baskets = EXCLUDED.baskets,
notifies = EXCLUDED.notifies,
date_last_sold = EXCLUDED.date_last_sold,
shop_score = EXCLUDED.shop_score,
primary_iid = EXCLUDED.primary_iid,
image = EXCLUDED.image,
image_175 = EXCLUDED.image_175,
@@ -614,8 +620,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen
AND t.barcode IS NOT DISTINCT FROM p.barcode
AND t.updated_at IS NOT DISTINCT FROM p.updated_at
AND t.total_sold IS NOT DISTINCT FROM p.total_sold
-- Check key fields that are likely to change
-- We don't need to check every single field, just the important ones
AND t.date_online IS NOT DISTINCT FROM p.date_online
AND t.shop_score IS NOT DISTINCT FROM p.shop_score
`);
// Get count of products that need updating
@@ -663,8 +669,13 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
// Setup temporary tables
await setupTemporaryTables(localConnection);
// Adjust sync time for mysql2 driver timezone vs MySQL server timezone mismatch
const mysqlSyncTime = prodConnection.adjustDateForMySQL
? prodConnection.adjustDateForMySQL(lastSyncTime)
: lastSyncTime;
// Materialize calculations into temp table
const materializeResult = await materializeCalculations(prodConnection, localConnection, incrementalUpdate, lastSyncTime, startTime);
const materializeResult = await materializeCalculations(prodConnection, localConnection, incrementalUpdate, mysqlSyncTime, startTime);
// Get the list of products that need updating
const [products] = await localConnection.query(`
@@ -688,8 +699,8 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
t.artist,
t.categories,
t.created_at,
t.date_online,
t.first_received,
t.landing_cost_price,
t.barcode,
t.harmonized_tariff_code,
t.updated_at,
@@ -710,6 +721,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
t.baskets,
t.notifies,
t.date_last_sold,
t.shop_score,
t.primary_iid,
t.image,
t.image_175,
@@ -728,8 +740,8 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
const batch = products.rows.slice(i, i + BATCH_SIZE);
const placeholders = batch.map((_, idx) => {
const base = idx * 47; // 47 columns
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
const base = idx * 48; // 48 columns (no primary_iid in this INSERT)
return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
}).join(',');
const values = batch.flatMap(row => {
@@ -754,8 +766,8 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
row.artist,
row.categories,
validateDate(row.created_at),
validateDate(row.date_online),
validateDate(row.first_received),
row.landing_cost_price,
row.barcode,
row.harmonized_tariff_code,
validateDate(row.updated_at),
@@ -777,6 +789,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
row.baskets,
row.notifies,
validateDate(row.date_last_sold),
Number(row.shop_score) || 0,
imageUrls.image,
imageUrls.image_175,
imageUrls.image_full,
@@ -790,11 +803,11 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
INSERT INTO products (
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
brand, line, subline, artist, categories, created_at, first_received,
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible,
brand, line, subline, artist, categories, created_at, date_online, first_received,
barcode, harmonized_tariff_code, updated_at, visible,
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
weight, length, width, height, country_of_origin, location, total_sold,
baskets, notifies, date_last_sold, image, image_175, image_full, options, tags
baskets, notifies, date_last_sold, shop_score, image, image_175, image_full, options, tags
)
VALUES ${placeholders}
ON CONFLICT (pid) DO UPDATE SET
@@ -815,8 +828,8 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
subline = EXCLUDED.subline,
artist = EXCLUDED.artist,
created_at = EXCLUDED.created_at,
date_online = EXCLUDED.date_online,
first_received = EXCLUDED.first_received,
landing_cost_price = EXCLUDED.landing_cost_price,
barcode = EXCLUDED.barcode,
harmonized_tariff_code = EXCLUDED.harmonized_tariff_code,
updated_at = EXCLUDED.updated_at,
@@ -838,6 +851,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
baskets = EXCLUDED.baskets,
notifies = EXCLUDED.notifies,
date_last_sold = EXCLUDED.date_last_sold,
shop_score = EXCLUDED.shop_score,
image = EXCLUDED.image,
image_175 = EXCLUDED.image_175,
image_full = EXCLUDED.image_full,

View File

@@ -65,8 +65,12 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'purchase_orders'"
);
const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01';
// Adjust for mysql2 driver timezone vs MySQL server timezone mismatch
const mysqlSyncTime = prodConnection.adjustDateForMySQL
? prodConnection.adjustDateForMySQL(lastSyncTime)
: lastSyncTime;
console.log('Purchase Orders: Using last sync time:', lastSyncTime);
console.log('Purchase Orders: Using last sync time:', lastSyncTime, '(adjusted:', mysqlSyncTime, ')');
// Create temp tables for processing
await localConnection.query(`
@@ -254,7 +258,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
OR p.date_estin > ?
)
` : ''}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
const totalPOs = poCount[0].total;
console.log(`Found ${totalPOs} relevant purchase orders`);
@@ -291,7 +295,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
` : ''}
ORDER BY p.po_id
LIMIT ${PO_BATCH_SIZE} OFFSET ${offset}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
if (poList.length === 0) {
allPOsProcessed = true;
@@ -426,7 +430,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
OR r.date_created > ?
)
` : ''}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime] : []);
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime] : []);
const totalReceivings = receivingCount[0].total;
console.log(`Found ${totalReceivings} relevant receivings`);
@@ -463,7 +467,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
` : ''}
ORDER BY r.receiving_id
LIMIT ${PO_BATCH_SIZE} OFFSET ${offset}
`, incrementalUpdate ? [lastSyncTime, lastSyncTime] : []);
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime] : []);
if (receivingList.length === 0) {
allReceivingsProcessed = true;

View File

@@ -0,0 +1,188 @@
const { outputProgress, formatElapsedTime, calculateRate } = require('../metrics-new/utils/progress');
const BATCH_SIZE = 5000;
/**
* Imports daily stock snapshots from MySQL's snap_product_value table to PostgreSQL.
* This provides historical end-of-day stock quantities per product, dating back to 2012.
*
* MySQL source table: snap_product_value (date, pid, count, pending, value)
* - date: snapshot date (typically yesterday's date, recorded daily by cron)
* - pid: product ID
* - count: end-of-day stock quantity (sum of product_inventory.count)
* - pending: pending/on-order quantity
* - value: total inventory value at cost (sum of costeach * count)
*
* PostgreSQL target table: stock_snapshots (snapshot_date, pid, stock_quantity, pending_quantity, stock_value)
*
* @param {object} prodConnection - MySQL connection to production DB
* @param {object} localConnection - PostgreSQL connection wrapper
* @param {boolean} incrementalUpdate - If true, only fetch new snapshots since last import
* @returns {object} Import statistics
*/
async function importStockSnapshots(prodConnection, localConnection, incrementalUpdate = true) {
const startTime = Date.now();
outputProgress({
status: 'running',
operation: 'Stock snapshots import',
message: 'Starting stock snapshots import...',
current: 0,
total: 0,
elapsed: formatElapsedTime(startTime)
});
// Ensure target table exists
await localConnection.query(`
CREATE TABLE IF NOT EXISTS stock_snapshots (
snapshot_date DATE NOT NULL,
pid BIGINT NOT NULL,
stock_quantity INT NOT NULL DEFAULT 0,
pending_quantity INT NOT NULL DEFAULT 0,
stock_value NUMERIC(14, 4) NOT NULL DEFAULT 0,
PRIMARY KEY (snapshot_date, pid)
)
`);
// Create index for efficient lookups by pid
await localConnection.query(`
CREATE INDEX IF NOT EXISTS idx_stock_snapshots_pid ON stock_snapshots (pid)
`);
// Determine the start date for the import
let startDate = '2020-01-01'; // Default: match the orders/snapshots date range
if (incrementalUpdate) {
const [result] = await localConnection.query(`
SELECT MAX(snapshot_date)::text AS max_date FROM stock_snapshots
`);
if (result.rows[0]?.max_date) {
// Start from the day after the last imported date
startDate = result.rows[0].max_date;
}
}
outputProgress({
status: 'running',
operation: 'Stock snapshots import',
message: `Fetching stock snapshots from MySQL since ${startDate}...`,
current: 0,
total: 0,
elapsed: formatElapsedTime(startTime)
});
// Count total rows to import
const [countResult] = await prodConnection.query(
`SELECT COUNT(*) AS total FROM snap_product_value WHERE date > ?`,
[startDate]
);
const totalRows = countResult[0].total;
if (totalRows === 0) {
outputProgress({
status: 'complete',
operation: 'Stock snapshots import',
message: 'No new stock snapshots to import',
current: 0,
total: 0,
elapsed: formatElapsedTime(startTime)
});
return { recordsAdded: 0, recordsUpdated: 0, status: 'complete' };
}
outputProgress({
status: 'running',
operation: 'Stock snapshots import',
message: `Found ${totalRows.toLocaleString()} stock snapshot rows to import`,
current: 0,
total: totalRows,
elapsed: formatElapsedTime(startTime)
});
// Process in batches using date-based pagination (more efficient than OFFSET)
let processedRows = 0;
let recordsAdded = 0;
let currentDate = startDate;
while (processedRows < totalRows) {
// Fetch a batch of dates
const [dateBatch] = await prodConnection.query(
`SELECT DISTINCT date FROM snap_product_value
WHERE date > ? ORDER BY date LIMIT 10`,
[currentDate]
);
if (dateBatch.length === 0) break;
const dates = dateBatch.map(r => r.date);
const lastDate = dates[dates.length - 1];
// Fetch all rows for these dates
const [rows] = await prodConnection.query(
`SELECT date, pid, count AS stock_quantity, pending AS pending_quantity, value AS stock_value
FROM snap_product_value
WHERE date > ? AND date <= ?
ORDER BY date, pid`,
[currentDate, lastDate]
);
if (rows.length === 0) break;
// Batch insert into PostgreSQL using UNNEST for efficiency
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
const batch = rows.slice(i, i + BATCH_SIZE);
const dates = batch.map(r => r.date);
const pids = batch.map(r => r.pid);
const quantities = batch.map(r => r.stock_quantity);
const pending = batch.map(r => r.pending_quantity);
const values = batch.map(r => r.stock_value);
try {
const [result] = await localConnection.query(`
INSERT INTO stock_snapshots (snapshot_date, pid, stock_quantity, pending_quantity, stock_value)
SELECT * FROM UNNEST(
$1::date[], $2::bigint[], $3::int[], $4::int[], $5::numeric[]
)
ON CONFLICT (snapshot_date, pid) DO UPDATE SET
stock_quantity = EXCLUDED.stock_quantity,
pending_quantity = EXCLUDED.pending_quantity,
stock_value = EXCLUDED.stock_value
`, [dates, pids, quantities, pending, values]);
recordsAdded += batch.length;
} catch (err) {
console.error(`Error inserting batch at offset ${i} (date range ending ${currentDate}):`, err.message);
}
}
processedRows += rows.length;
currentDate = lastDate;
outputProgress({
status: 'running',
operation: 'Stock snapshots import',
message: `Imported ${processedRows.toLocaleString()} / ${totalRows.toLocaleString()} rows (through ${currentDate})`,
current: processedRows,
total: totalRows,
elapsed: formatElapsedTime(startTime),
rate: calculateRate(processedRows, startTime)
});
}
outputProgress({
status: 'complete',
operation: 'Stock snapshots import',
message: `Stock snapshots import complete: ${recordsAdded.toLocaleString()} rows`,
current: processedRows,
total: totalRows,
elapsed: formatElapsedTime(startTime)
});
return {
recordsAdded,
recordsUpdated: 0,
status: 'complete'
};
}
module.exports = importStockSnapshots;

View File

@@ -48,6 +48,37 @@ async function setupConnections(sshConfig) {
stream: tunnel.stream,
});
// Detect MySQL server timezone and calculate correction for the driver timezone mismatch.
// The mysql2 driver is configured with timezone: '-05:00' (EST), but the MySQL server
// may be in a different timezone (e.g., America/Chicago = CST/CDT). When the driver
// formats a JS Date as EST and MySQL interprets it in its own timezone, DATETIME
// comparisons can be off. This correction adjusts Date objects before they're passed
// to MySQL queries so the formatted string matches the server's local time.
const [[{ utcDiffSec }]] = await prodConnection.query(
"SELECT TIMESTAMPDIFF(SECOND, NOW(), UTC_TIMESTAMP()) as utcDiffSec"
);
const mysqlOffsetMs = -utcDiffSec * 1000; // MySQL UTC offset in ms (e.g., -21600000 for CST)
const driverOffsetMs = -5 * 3600 * 1000; // Driver's -05:00 in ms (-18000000)
const tzCorrectionMs = driverOffsetMs - mysqlOffsetMs;
// CST (winter): -18000000 - (-21600000) = +3600000 (1 hour correction needed)
// CDT (summer): -18000000 - (-18000000) = 0 (no correction needed)
if (tzCorrectionMs !== 0) {
console.log(`MySQL timezone correction: ${tzCorrectionMs / 1000}s (server offset: ${utcDiffSec}s from UTC)`);
}
/**
* Adjusts a Date/timestamp for the mysql2 driver timezone mismatch before
* passing it as a query parameter to MySQL. This ensures that the string
* mysql2 generates matches the timezone that DATETIME values are stored in.
*/
function adjustDateForMySQL(date) {
if (!date || tzCorrectionMs === 0) return date;
const d = date instanceof Date ? date : new Date(date);
return new Date(d.getTime() - tzCorrectionMs);
}
prodConnection.adjustDateForMySQL = adjustDateForMySQL;
// Setup PostgreSQL connection pool for local
const localPool = new Pool(sshConfig.localDbConfig);

View File

@@ -27,7 +27,7 @@ BEGIN
p.visible as is_visible, p.replenishable,
COALESCE(p.price, 0.00) as current_price, COALESCE(p.regular_price, 0.00) as current_regular_price,
COALESCE(p.cost_price, 0.00) as current_cost_price,
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as current_effective_cost, -- Use landing if available, else cost
COALESCE(p.cost_price, 0.00) as current_effective_cost,
p.stock_quantity as current_stock, -- Use actual current stock for forecast base
p.created_at, p.first_received, p.date_last_sold,
p.moq,
@@ -214,7 +214,7 @@ BEGIN
-- Final INSERT/UPDATE statement using all the prepared CTEs
INSERT INTO public.product_metrics (
pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable,
current_price, current_regular_price, current_cost_price, current_landing_cost_price,
current_price, current_regular_price, current_cost_price,
current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
date_created, date_first_received, date_last_received, date_first_sold, date_last_sold, age_days,
@@ -242,7 +242,7 @@ BEGIN
SELECT
-- Select columns in order, joining all CTEs by pid
ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.replenishable,
ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost,
ci.current_price, ci.current_regular_price, ci.current_cost_price,
ci.current_stock, (ci.current_stock * COALESCE(ci.current_effective_cost, 0.00))::numeric(12,2), (ci.current_stock * COALESCE(ci.current_price, 0.00))::numeric(12,2), (ci.current_stock * COALESCE(ci.current_regular_price, 0.00))::numeric(12,2),
COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00)::numeric(12,2), (COALESCE(ooi.on_order_qty, 0) * COALESCE(ci.current_price, 0.00))::numeric(12,2), ooi.earliest_expected_date,
@@ -415,7 +415,7 @@ BEGIN
-- *** IMPORTANT: List ALL columns here, ensuring order matches INSERT list ***
-- Update ALL columns to ensure entire row is refreshed
last_calculated = EXCLUDED.last_calculated, sku = EXCLUDED.sku, title = EXCLUDED.title, brand = EXCLUDED.brand, vendor = EXCLUDED.vendor, image_url = EXCLUDED.image_url, is_visible = EXCLUDED.is_visible, is_replenishable = EXCLUDED.is_replenishable,
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price,
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price,
current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross,
on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date,
date_created = EXCLUDED.date_created, date_first_received = EXCLUDED.date_first_received, date_last_received = EXCLUDED.date_last_received, date_first_sold = EXCLUDED.date_first_sold, date_last_sold = EXCLUDED.date_last_sold, age_days = EXCLUDED.age_days,

View File

@@ -10,7 +10,7 @@ DECLARE
_date DATE;
_count INT;
_total_records INT := 0;
_begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2024-01-01'); -- Starting point for data rebuild
_begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2020-01-01'); -- Starting point: captures all historical order data
_end_date DATE := CURRENT_DATE;
BEGIN
RAISE NOTICE 'Beginning daily snapshots rebuild from % to %. Starting at %', _begin_date, _end_date, _start_time;
@@ -36,7 +36,13 @@ BEGIN
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN
COALESCE(
o.costeach,
get_weighted_avg_cost(p.pid, o.date::date),
p.cost_price
) * o.quantity
ELSE 0 END), 0.00) AS cogs,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue,
-- Aggregate Returns (Quantity < 0 or Status = Returned)
@@ -63,15 +69,17 @@ BEGIN
GROUP BY r.pid
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
),
-- Get stock quantities for the day - note this is approximate since we're using current products data
-- Use historical stock from stock_snapshots when available,
-- falling back to current stock from products table
StockData AS (
SELECT
p.pid,
p.stock_quantity,
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as effective_cost_price,
COALESCE(ss.stock_quantity, p.stock_quantity) AS stock_quantity,
COALESCE(ss.stock_value, p.stock_quantity * COALESCE(p.cost_price, 0.00)) AS stock_value,
COALESCE(p.price, 0.00) as current_price,
COALESCE(p.regular_price, 0.00) as current_regular_price
FROM public.products p
LEFT JOIN stock_snapshots ss ON p.pid = ss.pid AND ss.snapshot_date = _date
)
INSERT INTO public.daily_product_snapshots (
snapshot_date,
@@ -99,9 +107,9 @@ BEGIN
_date AS snapshot_date,
COALESCE(sd.pid, rd.pid) AS pid,
sd.sku,
-- Use current stock as approximation, since historical stock data may not be available
-- Historical stock from stock_snapshots, falls back to current stock
s.stock_quantity AS eod_stock_quantity,
s.stock_quantity * s.effective_cost_price AS eod_stock_cost,
s.stock_value AS eod_stock_cost,
s.stock_quantity * s.current_price AS eod_stock_retail,
s.stock_quantity * s.current_regular_price AS eod_stock_gross,
(s.stock_quantity <= 0) AS stockout_flag,
@@ -111,10 +119,10 @@ BEGIN
COALESCE(sd.gross_revenue_unadjusted, 0.00),
COALESCE(sd.discounts, 0.00),
COALESCE(sd.returns_revenue, 0.00),
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue,
COALESCE(sd.cogs, 0.00),
COALESCE(sd.gross_regular_revenue, 0.00),
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
-- Receiving metrics
COALESCE(rd.units_received, 0),
COALESCE(rd.cost_received, 0.00),

View File

@@ -23,21 +23,21 @@ BEGIN
-- Only include products with valid sales data in each time period
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
SUM(COALESCE(pm.revenue_7d, 0)) AS revenue_7d,
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
SUM(COALESCE(pm.revenue_30d, 0)) AS revenue_30d,
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
SUM(COALESCE(pm.revenue_365d, 0)) AS revenue_365d,
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
SUM(COALESCE(pm.lifetime_revenue, 0)) AS lifetime_revenue
FROM public.product_metrics pm
JOIN public.products p ON pm.pid = p.pid
GROUP BY brand_group

View File

@@ -28,8 +28,8 @@ BEGIN
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
@@ -38,58 +38,56 @@ BEGIN
JOIN public.product_metrics pm ON pc.pid = pm.pid
GROUP BY pc.cat_id
),
-- Calculate rolled-up metrics (including all descendant categories)
-- Map each category to ALL distinct products in it or any descendant.
-- Uses the path array from category_hierarchy: for product P in category C,
-- P contributes to C and every ancestor in C's path.
-- DISTINCT ensures each (ancestor, pid) pair appears only once, preventing
-- double-counting when a product belongs to multiple categories under the same parent.
CategoryProducts AS (
SELECT DISTINCT
ancestor_cat_id,
pc.pid
FROM public.product_categories pc
JOIN category_hierarchy ch ON pc.cat_id = ch.cat_id
CROSS JOIN LATERAL unnest(ch.path) AS ancestor_cat_id
),
-- Calculate rolled-up metrics using deduplicated product sets
RolledUpMetrics AS (
SELECT
ch.cat_id,
-- Sum metrics from this category and all its descendants
SUM(dcm.product_count) AS product_count,
SUM(dcm.active_product_count) AS active_product_count,
SUM(dcm.replenishable_product_count) AS replenishable_product_count,
SUM(dcm.current_stock_units) AS current_stock_units,
SUM(dcm.current_stock_cost) AS current_stock_cost,
SUM(dcm.current_stock_retail) AS current_stock_retail,
SUM(dcm.sales_7d) AS sales_7d,
SUM(dcm.revenue_7d) AS revenue_7d,
SUM(dcm.sales_30d) AS sales_30d,
SUM(dcm.revenue_30d) AS revenue_30d,
SUM(dcm.cogs_30d) AS cogs_30d,
SUM(dcm.profit_30d) AS profit_30d,
SUM(dcm.sales_365d) AS sales_365d,
SUM(dcm.revenue_365d) AS revenue_365d,
SUM(dcm.lifetime_sales) AS lifetime_sales,
SUM(dcm.lifetime_revenue) AS lifetime_revenue
FROM category_hierarchy ch
LEFT JOIN DirectCategoryMetrics dcm ON
dcm.cat_id = ch.cat_id OR
dcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
GROUP BY ch.cat_id
cp.ancestor_cat_id AS cat_id,
COUNT(DISTINCT cp.pid) AS product_count,
COUNT(DISTINCT CASE WHEN pm.is_visible THEN cp.pid END) AS active_product_count,
COUNT(DISTINCT CASE WHEN pm.is_replenishable THEN cp.pid END) AS replenishable_product_count,
SUM(pm.current_stock) AS current_stock_units,
SUM(pm.current_stock_cost) AS current_stock_cost,
SUM(pm.current_stock_retail) AS current_stock_retail,
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
FROM CategoryProducts cp
JOIN public.product_metrics pm ON cp.pid = pm.pid
GROUP BY cp.ancestor_cat_id
),
PreviousPeriodCategoryMetrics AS (
-- Get previous period metrics for growth calculation
-- Previous period rolled up using same deduplicated product sets
RolledUpPreviousPeriod AS (
SELECT
pc.cat_id,
cp.ancestor_cat_id AS cat_id,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
FROM public.daily_product_snapshots dps
JOIN public.product_categories pc ON dps.pid = pc.pid
GROUP BY pc.cat_id
),
RolledUpPreviousPeriod AS (
-- Calculate rolled-up previous period metrics
SELECT
ch.cat_id,
SUM(ppcm.sales_prev_30d) AS sales_prev_30d,
SUM(ppcm.revenue_prev_30d) AS revenue_prev_30d
FROM category_hierarchy ch
LEFT JOIN PreviousPeriodCategoryMetrics ppcm ON
ppcm.cat_id = ch.cat_id OR
ppcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
GROUP BY ch.cat_id
FROM CategoryProducts cp
JOIN public.daily_product_snapshots dps ON cp.pid = dps.pid
GROUP BY cp.ancestor_cat_id
),
AllCategories AS (
-- Ensure all categories are included

View File

@@ -24,21 +24,21 @@ BEGIN
-- Only include products with valid sales data in each time period
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
SUM(COALESCE(pm.revenue_7d, 0)) AS revenue_7d,
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
SUM(COALESCE(pm.revenue_30d, 0)) AS revenue_30d,
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
SUM(COALESCE(pm.revenue_365d, 0)) AS revenue_365d,
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
SUM(COALESCE(pm.lifetime_revenue, 0)) AS lifetime_revenue
FROM public.product_metrics pm
JOIN public.products p ON pm.pid = p.pid
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
@@ -72,7 +72,7 @@ BEGIN
END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs
FROM public.purchase_orders po
-- Join to receivings table to find when items were received
LEFT JOIN public.receivings r ON r.pid = po.pid
LEFT JOIN public.receivings r ON r.pid = po.pid AND r.supplier_id = po.supplier_id
WHERE po.vendor IS NOT NULL AND po.vendor <> ''
AND po.date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year
AND po.status = 'done' -- Only calculate lead time on completed POs

View File

@@ -0,0 +1,38 @@
-- Migration: Map existing numeric order statuses to text values
-- Run this ONCE on the production PostgreSQL database after deploying the updated orders import.
-- This updates ~2.88M rows. On a busy system, consider running during low-traffic hours.
-- The WHERE clause ensures idempotency - only rows with numeric statuses are updated.
UPDATE orders SET status = CASE status
WHEN '0' THEN 'created'
WHEN '10' THEN 'unfinished'
WHEN '15' THEN 'canceled'
WHEN '16' THEN 'combined'
WHEN '20' THEN 'placed'
WHEN '22' THEN 'placed_incomplete'
WHEN '30' THEN 'canceled'
WHEN '40' THEN 'awaiting_payment'
WHEN '50' THEN 'awaiting_products'
WHEN '55' THEN 'shipping_later'
WHEN '56' THEN 'shipping_together'
WHEN '60' THEN 'ready'
WHEN '61' THEN 'flagged'
WHEN '62' THEN 'fix_before_pick'
WHEN '65' THEN 'manual_picking'
WHEN '70' THEN 'in_pt'
WHEN '80' THEN 'picked'
WHEN '90' THEN 'awaiting_shipment'
WHEN '91' THEN 'remote_wait'
WHEN '92' THEN 'awaiting_pickup'
WHEN '93' THEN 'fix_before_ship'
WHEN '95' THEN 'shipped_confirmed'
WHEN '100' THEN 'shipped'
ELSE status
END
WHERE status ~ '^\d+$'; -- Only update rows that still have numeric statuses
-- Verify the migration
SELECT status, COUNT(*) as count
FROM orders
GROUP BY status
ORDER BY count DESC;

View File

@@ -0,0 +1,51 @@
-- Migration 002: Fix discount double-counting in orders
--
-- PROBLEM: The orders import was calculating discount as:
-- discount = (prod_price_reg - prod_price) * quantity <-- "sale savings" (WRONG)
-- + prorated points discount
-- + item-level promo discounts
--
-- Since `price` in the orders table already IS the sale price (prod_price, not prod_price_reg),
-- the "sale savings" component double-counted the markdown. This resulted in inflated discounts
-- and near-zero net_revenue for products sold on sale.
--
-- Example: Product with regular_price=$30, sale_price=$15, qty=2
-- BEFORE (buggy): discount = ($30-$15)*2 + 0 + 0 = $30.00
-- net_revenue = $15*2 - $30 = $0.00 (WRONG!)
-- AFTER (fixed): discount = 0 + 0 + 0 = $0.00
-- net_revenue = $15*2 - $0 = $30.00 (CORRECT!)
--
-- FIX: This cannot be fixed with a pure SQL migration because PostgreSQL doesn't store
-- prod_price_reg. The discount column has the inflated value baked in, and we can't
-- decompose which portion was the base_discount vs actual promo discounts.
--
-- REQUIRED ACTION: Run a FULL (non-incremental) orders re-import after deploying the
-- fixed orders.js. This will recalculate all discounts using the corrected formula.
--
-- Steps:
-- 1. Deploy updated orders.js (base_discount removed from discount calculation)
-- 2. Run: node scripts/import/orders.js --full
-- (or trigger a full sync through whatever mechanism is used)
-- 3. After re-import, run the daily snapshots rebuild to propagate corrected revenue:
-- psql -f scripts/metrics-new/backfill/rebuild_daily_snapshots.sql
-- 4. Re-run metrics calculation:
-- node scripts/metrics-new/calculate-metrics-new.js
--
-- VERIFICATION: After re-import, check the previously-affected products:
SELECT
o.pid,
p.title,
o.order_number,
o.price,
o.quantity,
o.discount,
(o.price * o.quantity) as gross_revenue,
(o.price * o.quantity - o.discount) as net_revenue
FROM orders o
JOIN products p ON o.pid = p.pid
WHERE o.pid IN (624756, 614513)
ORDER BY o.date DESC
LIMIT 10;
-- Expected: discount should be 0 (or small promo amount) for regular sales,
-- and net_revenue should be close to gross_revenue.

View File

@@ -1,75 +1,109 @@
-- Description: Calculates and updates daily aggregated product data for recent days.
-- Uses UPSERT (INSERT ON CONFLICT UPDATE) for idempotency.
-- Description: Calculates and updates daily aggregated product data.
-- Self-healing: detects gaps (missing snapshots), stale data (snapshot
-- aggregates that don't match source tables after backfills), and always
-- reprocesses recent days to pick up new orders and data corrections.
-- Dependencies: Core import tables (products, orders, purchase_orders), calculate_status table.
-- Frequency: Hourly (Run ~5-10 minutes after hourly data import completes).
DO $$
DECLARE
_module_name TEXT := 'daily_snapshots';
_start_time TIMESTAMPTZ := clock_timestamp(); -- Time execution started
_last_calc_time TIMESTAMPTZ;
_target_date DATE; -- Will be set in the loop
_start_time TIMESTAMPTZ := clock_timestamp();
_target_date DATE;
_total_records INT := 0;
_has_orders BOOLEAN := FALSE;
_process_days INT := 5; -- Number of days to check/process (today plus previous 4 days)
_day_counter INT;
_missing_days INT[] := ARRAY[]::INT[]; -- Array to store days with missing or incomplete data
_days_processed INT := 0;
_max_backfill_days INT := 90; -- Safety cap: max days to backfill per run
_recent_recheck_days INT := 2; -- Always reprocess this many recent days (today + yesterday)
_latest_snapshot DATE;
_backfill_start DATE;
BEGIN
-- Get the timestamp before the last successful run of this module
SELECT last_calculation_timestamp INTO _last_calc_time
FROM public.calculate_status
WHERE module_name = _module_name;
RAISE NOTICE 'Running % script. Start Time: %', _module_name, _start_time;
-- First, check which days need processing by comparing orders data with snapshot data
FOR _day_counter IN 0..(_process_days-1) LOOP
_target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day');
-- Find the latest existing snapshot date (for logging only)
SELECT MAX(snapshot_date) INTO _latest_snapshot
FROM public.daily_product_snapshots;
-- Check if this date needs updating by comparing orders to snapshot data
-- If the date has orders but not enough snapshots, or if snapshots show zero sales but orders exist, it's incomplete
SELECT
CASE WHEN (
-- We have orders for this date but not enough snapshots, or snapshots with wrong total
(EXISTS (SELECT 1 FROM public.orders WHERE date::date = _target_date) AND
(
-- No snapshots exist for this date
NOT EXISTS (SELECT 1 FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) OR
-- Or snapshots show zero sales but orders exist
(SELECT COALESCE(SUM(units_sold), 0) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) = 0 OR
-- Or the count of snapshot records is significantly less than distinct products in orders
(SELECT COUNT(*) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) <
(SELECT COUNT(DISTINCT pid) FROM public.orders WHERE date::date = _target_date) * 0.8
)
)
) THEN TRUE ELSE FALSE END
INTO _has_orders;
-- Always scan the full backfill window to catch holes in the middle,
-- not just gaps at the end. The gap fill and stale detection queries
-- need to see the entire range to find missing or outdated snapshots.
_backfill_start := CURRENT_DATE - _max_backfill_days;
IF _has_orders THEN
-- This day needs processing - add to our array
_missing_days := _missing_days || _day_counter;
RAISE NOTICE 'Day % needs updating (incomplete or missing data)', _target_date;
END IF;
END LOOP;
-- If no days need updating, exit early
IF array_length(_missing_days, 1) IS NULL THEN
RAISE NOTICE 'No days need updating - all snapshot data appears complete';
-- Still update the calculate_status to record this run
UPDATE public.calculate_status
SET last_calculation_timestamp = _start_time
WHERE module_name = _module_name;
RETURN;
IF _latest_snapshot IS NULL THEN
RAISE NOTICE 'No existing snapshots found. Backfilling up to % days.', _max_backfill_days;
ELSE
RAISE NOTICE 'Latest snapshot: %. Scanning from % for gaps and stale data.', _latest_snapshot, _backfill_start;
END IF;
RAISE NOTICE 'Need to update % days with missing or incomplete data', array_length(_missing_days, 1);
-- Process all dates that need snapshots:
-- 1. Gap fill: dates with orders/receivings but no snapshots (older than recent window)
-- 2. Stale detection: existing snapshots where aggregates don't match source data
-- (catches backfilled imports that arrived after snapshot was calculated)
-- 3. Recent recheck: last N days always reprocessed (picks up new orders, corrections)
FOR _target_date IN
SELECT d FROM (
-- Gap fill: find dates with activity but missing snapshots
SELECT activity_dates.d
FROM (
SELECT DISTINCT date::date AS d FROM public.orders
WHERE date::date >= _backfill_start AND date::date < CURRENT_DATE - _recent_recheck_days
UNION
SELECT DISTINCT received_date::date AS d FROM public.receivings
WHERE received_date::date >= _backfill_start AND received_date::date < CURRENT_DATE - _recent_recheck_days
) activity_dates
WHERE NOT EXISTS (
SELECT 1 FROM public.daily_product_snapshots dps WHERE dps.snapshot_date = activity_dates.d
)
UNION
-- Stale detection: compare snapshot aggregates against source tables
SELECT snap_agg.snapshot_date AS d
FROM (
SELECT snapshot_date,
COALESCE(SUM(units_received), 0)::bigint AS snap_received,
COALESCE(SUM(units_sold), 0)::bigint AS snap_sold
FROM public.daily_product_snapshots
WHERE snapshot_date >= _backfill_start
AND snapshot_date < CURRENT_DATE - _recent_recheck_days
GROUP BY snapshot_date
) snap_agg
LEFT JOIN (
SELECT received_date::date AS d, SUM(qty_each)::bigint AS actual_received
FROM public.receivings
WHERE received_date::date >= _backfill_start
AND received_date::date < CURRENT_DATE - _recent_recheck_days
GROUP BY received_date::date
) recv_agg ON snap_agg.snapshot_date = recv_agg.d
LEFT JOIN (
SELECT date::date AS d,
SUM(CASE WHEN quantity > 0 AND COALESCE(status, 'pending') NOT IN ('canceled', 'returned')
THEN quantity ELSE 0 END)::bigint AS actual_sold
FROM public.orders
WHERE date::date >= _backfill_start
AND date::date < CURRENT_DATE - _recent_recheck_days
GROUP BY date::date
) orders_agg ON snap_agg.snapshot_date = orders_agg.d
WHERE snap_agg.snap_received != COALESCE(recv_agg.actual_received, 0)
OR snap_agg.snap_sold != COALESCE(orders_agg.actual_sold, 0)
UNION
-- Recent days: always reprocess
SELECT d::date
FROM generate_series(
(CURRENT_DATE - _recent_recheck_days)::timestamp,
CURRENT_DATE::timestamp,
'1 day'::interval
) d
) dates_to_process
ORDER BY d
LOOP
_days_processed := _days_processed + 1;
-- Process only the days that need updating
FOREACH _day_counter IN ARRAY _missing_days LOOP
_target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day');
RAISE NOTICE 'Processing date: %', _target_date;
-- Classify why this date is being processed (for logging)
IF _target_date >= CURRENT_DATE - _recent_recheck_days THEN
RAISE NOTICE 'Processing date: % [recent recheck]', _target_date;
ELSIF NOT EXISTS (SELECT 1 FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) THEN
RAISE NOTICE 'Processing date: % [gap fill — no existing snapshot]', _target_date;
ELSE
RAISE NOTICE 'Processing date: % [stale data — snapshot aggregates mismatch source]', _target_date;
END IF;
-- IMPORTANT: First delete any existing data for this date to prevent duplication
DELETE FROM public.daily_product_snapshots
@@ -90,7 +124,6 @@ BEGIN
COALESCE(
o.costeach, -- First use order-specific cost if available
get_weighted_avg_cost(p.pid, o.date::date), -- Then use weighted average cost
p.landing_cost_price, -- Fallback to landing cost
p.cost_price -- Final fallback to current cost
) * o.quantity
ELSE 0 END), 0.00) AS cogs,
@@ -124,14 +157,16 @@ BEGIN
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
),
CurrentStock AS (
-- Select current stock values directly from products table
-- Use historical stock from stock_snapshots when available,
-- falling back to current stock from products table
SELECT
pid,
stock_quantity,
COALESCE(landing_cost_price, cost_price, 0.00) as effective_cost_price,
COALESCE(price, 0.00) as current_price,
COALESCE(regular_price, 0.00) as current_regular_price
FROM public.products
p.pid,
COALESCE(ss.stock_quantity, p.stock_quantity) AS stock_quantity,
COALESCE(ss.stock_value, p.stock_quantity * COALESCE(p.cost_price, 0.00)) AS stock_value,
COALESCE(p.price, 0.00) AS current_price,
COALESCE(p.regular_price, 0.00) AS current_regular_price
FROM public.products p
LEFT JOIN stock_snapshots ss ON p.pid = ss.pid AND ss.snapshot_date = _target_date
),
ProductsWithActivity AS (
-- Quick pre-filter to only process products with activity
@@ -171,7 +206,7 @@ BEGIN
COALESCE(sd.sku, p.sku) AS sku, -- Get SKU from sales data or products table
-- Inventory Metrics (Using CurrentStock)
cs.stock_quantity AS eod_stock_quantity,
cs.stock_quantity * cs.effective_cost_price AS eod_stock_cost,
cs.stock_value AS eod_stock_cost,
cs.stock_quantity * cs.current_price AS eod_stock_retail,
cs.stock_quantity * cs.current_regular_price AS eod_stock_gross,
(cs.stock_quantity <= 0) AS stockout_flag,
@@ -181,10 +216,10 @@ BEGIN
COALESCE(sd.gross_revenue_unadjusted, 0.00),
COALESCE(sd.discounts, 0.00),
COALESCE(sd.returns_revenue, 0.00),
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue,
COALESCE(sd.cogs, 0.00),
COALESCE(sd.gross_regular_revenue, 0.00),
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit, -- Basic profit: Net Revenue - COGS
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
-- Receiving Metrics (From ReceivingData)
COALESCE(rd.units_received, 0),
COALESCE(rd.cost_received, 0.00),
@@ -201,12 +236,18 @@ BEGIN
RAISE NOTICE 'Created % daily snapshot records for % with sales/receiving activity', _total_records, _target_date;
END LOOP;
-- Update the status table with the timestamp from the START of this run
UPDATE public.calculate_status
SET last_calculation_timestamp = _start_time
WHERE module_name = _module_name;
IF _days_processed = 0 THEN
RAISE NOTICE 'No days need updating — all snapshot data is current.';
ELSE
RAISE NOTICE 'Processed % days total.', _days_processed;
END IF;
RAISE NOTICE 'Finished % processing for multiple dates. Duration: %', _module_name, clock_timestamp() - _start_time;
-- Update the status table with the timestamp from the START of this run
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
VALUES (_module_name, _start_time)
ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = _start_time;
RAISE NOTICE 'Finished % script. Duration: %', _module_name, clock_timestamp() - _start_time;
END $$;

View File

@@ -0,0 +1,131 @@
-- Description: Populates lifecycle forecast columns on product_metrics from product_forecasts.
-- Runs AFTER update_product_metrics.sql so that lead time / days of stock settings are available.
-- Dependencies: product_metrics (fully populated), product_forecasts, settings tables.
-- Frequency: After each metrics run and/or after forecast engine runs.
DO $$
DECLARE
_module_name TEXT := 'lifecycle_forecasts';
_start_time TIMESTAMPTZ := clock_timestamp();
_updated INT;
BEGIN
RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time;
-- Step 1: Set lifecycle_phase from product_forecasts (one phase per product)
UPDATE product_metrics pm
SET lifecycle_phase = sub.lifecycle_phase
FROM (
SELECT DISTINCT ON (pid) pid, lifecycle_phase
FROM product_forecasts
ORDER BY pid, forecast_date
) sub
WHERE pm.pid = sub.pid
AND (pm.lifecycle_phase IS DISTINCT FROM sub.lifecycle_phase);
GET DIAGNOSTICS _updated = ROW_COUNT;
RAISE NOTICE 'Updated lifecycle_phase for % products', _updated;
-- Step 2: Compute lifecycle-based lead time and planning period forecasts
-- Uses each product's configured lead time and days of stock
WITH forecast_sums AS (
SELECT
pf.pid,
SUM(pf.forecast_units) FILTER (
WHERE pf.forecast_date <= CURRENT_DATE + s.effective_lead_time
) AS lt_forecast,
SUM(pf.forecast_units) FILTER (
WHERE pf.forecast_date <= CURRENT_DATE + s.effective_lead_time + s.effective_days_of_stock
) AS pp_forecast
FROM product_forecasts pf
JOIN (
SELECT
p.pid,
COALESCE(sp.lead_time_days, sv.default_lead_time_days,
(SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_lead_time_days'), 14
) AS effective_lead_time,
COALESCE(sp.days_of_stock, sv.default_days_of_stock,
(SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_days_of_stock'), 30
) AS effective_days_of_stock
FROM products p
LEFT JOIN settings_product sp ON p.pid = sp.pid
LEFT JOIN settings_vendor sv ON p.vendor = sv.vendor
) s ON s.pid = pf.pid
WHERE pf.forecast_date >= CURRENT_DATE
GROUP BY pf.pid
)
UPDATE product_metrics pm
SET
lifecycle_lead_time_forecast = COALESCE(fs.lt_forecast, 0),
lifecycle_planning_period_forecast = COALESCE(fs.pp_forecast, 0)
FROM forecast_sums fs
WHERE pm.pid = fs.pid
AND (pm.lifecycle_lead_time_forecast IS DISTINCT FROM COALESCE(fs.lt_forecast, 0)
OR pm.lifecycle_planning_period_forecast IS DISTINCT FROM COALESCE(fs.pp_forecast, 0));
GET DIAGNOSTICS _updated = ROW_COUNT;
RAISE NOTICE 'Updated lifecycle forecasts for % products', _updated;
-- Step 3: Reclassify demand_pattern using residual CV (de-trended)
-- For launch/decay products, raw CV is high because of expected lifecycle decay.
-- We subtract the expected brand curve value to get residuals, then compute CV on those.
-- Products that track their brand curve closely → low residual CV → "stable"
-- Products with erratic deviations from curve → higher residual CV → "variable"/"sporadic"
WITH product_curve AS (
-- Get each product's brand curve and age
SELECT
pm.pid,
pm.lifecycle_phase,
pm.date_first_received,
blc.amplitude,
blc.decay_rate,
blc.baseline
FROM product_metrics pm
JOIN products p ON p.pid = pm.pid
LEFT JOIN brand_lifecycle_curves blc
ON blc.brand = pm.brand
AND blc.root_category IS NULL -- brand-only curve
WHERE pm.lifecycle_phase IN ('launch', 'decay')
AND pm.date_first_received IS NOT NULL
AND blc.amplitude IS NOT NULL
),
daily_residuals AS (
-- Compute residual = actual - expected for each snapshot day
-- Curve params are in WEEKLY units; divide by 7 to get daily expected
SELECT
dps.pid,
dps.units_sold,
(pc.amplitude * EXP(-pc.decay_rate * (dps.snapshot_date - pc.date_first_received)::numeric / 7.0) + pc.baseline) / 7.0 AS expected,
dps.units_sold - (pc.amplitude * EXP(-pc.decay_rate * (dps.snapshot_date - pc.date_first_received)::numeric / 7.0) + pc.baseline) / 7.0 AS residual
FROM daily_product_snapshots dps
JOIN product_curve pc ON pc.pid = dps.pid
WHERE dps.snapshot_date >= CURRENT_DATE - INTERVAL '29 days'
AND dps.snapshot_date <= CURRENT_DATE
),
residual_cv AS (
SELECT
pid,
AVG(units_sold) AS avg_sales,
CASE WHEN COUNT(*) >= 7 AND AVG(ABS(expected)) > 0.01 THEN
STDDEV_POP(residual) / GREATEST(AVG(ABS(expected)), 0.1)
END AS res_cv
FROM daily_residuals
GROUP BY pid
)
UPDATE product_metrics pm
SET demand_pattern = classify_demand_pattern(rc.avg_sales, rc.res_cv)
FROM residual_cv rc
WHERE pm.pid = rc.pid
AND rc.res_cv IS NOT NULL
AND pm.demand_pattern IS DISTINCT FROM classify_demand_pattern(rc.avg_sales, rc.res_cv);
GET DIAGNOSTICS _updated = ROW_COUNT;
RAISE NOTICE 'Reclassified demand_pattern for % launch/decay products', _updated;
-- Update tracking
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
VALUES (_module_name, clock_timestamp())
ON CONFLICT (module_name) DO UPDATE SET
last_calculation_timestamp = EXCLUDED.last_calculation_timestamp;
RAISE NOTICE '% module complete. Duration: %', _module_name, clock_timestamp() - _start_time;
END $$;

View File

@@ -21,20 +21,30 @@ BEGIN
RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time;
-- 1. Calculate Average Lead Time
-- For each completed PO, find the earliest receiving from the same supplier
-- within 180 days, then average those per-PO lead times per product.
RAISE NOTICE 'Calculating Average Lead Time...';
WITH LeadTimes AS (
WITH po_first_receiving AS (
SELECT
po.pid,
-- Calculate lead time by looking at when items ordered on POs were received
AVG(GREATEST(1, (r.received_date::date - po.date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days
po.po_id,
po.date::date AS po_date,
MIN(r.received_date::date) AS first_receive_date
FROM public.purchase_orders po
-- Join to receivings table to find actual receipts
JOIN public.receivings r ON r.pid = po.pid
WHERE po.status = 'done' -- Only include completed POs
AND r.received_date >= po.date -- Ensure received date is not before order date
-- Optional: add check to make sure receiving is related to PO if you have source_po_id
-- AND (r.source_po_id = po.po_id OR r.source_po_id IS NULL)
GROUP BY po.pid
JOIN public.receivings r
ON r.pid = po.pid
AND r.supplier_id = po.supplier_id -- same supplier
AND r.received_date >= po.date -- received after order
AND r.received_date <= po.date + INTERVAL '180 days' -- within reasonable window
WHERE po.status = 'done'
GROUP BY po.pid, po.po_id, po.date
),
LeadTimes AS (
SELECT
pid,
ROUND(AVG(GREATEST(1, first_receive_date - po_date))) AS avg_days
FROM po_first_receiving
GROUP BY pid
)
UPDATE public.product_metrics pm
SET avg_lead_time_days = lt.avg_days::int

View File

@@ -52,7 +52,7 @@ BEGIN
COALESCE(p.price, 0.00) as current_price,
COALESCE(p.regular_price, 0.00) as current_regular_price,
COALESCE(p.cost_price, 0.00) as current_cost_price,
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as current_effective_cost, -- Use landing if available, else cost
COALESCE(p.cost_price, 0.00) as current_effective_cost,
p.stock_quantity as current_stock,
p.created_at,
p.first_received,
@@ -61,16 +61,72 @@ BEGIN
p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each)
FROM public.products p
),
-- Stale POs: open, >90 days past expected, AND a newer PO exists for the same product.
-- These are likely abandoned/superseded and should not consume receivings in FIFO.
StalePOLines AS (
SELECT po.po_id, po.pid
FROM public.purchase_orders po
WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent',
'electronically_ready_send', 'receiving_started')
AND po.expected_date IS NOT NULL
AND po.expected_date < _current_date - INTERVAL '90 days'
AND EXISTS (
SELECT 1 FROM public.purchase_orders newer
WHERE newer.pid = po.pid
AND newer.status NOT IN ('canceled', 'done')
AND COALESCE(newer.date_ordered, newer.date_created)
> COALESCE(po.date_ordered, po.date_created)
)
),
-- All non-canceled, non-stale POs in FIFO order per (pid, supplier).
-- Includes closed ('done') POs so they consume receivings before open POs.
POFifo AS (
SELECT
po.pid, po.supplier_id, po.po_id, po.ordered, po.status,
po.po_cost_price, po.expected_date,
SUM(po.ordered) OVER (
PARTITION BY po.pid, po.supplier_id
ORDER BY COALESCE(po.date_ordered, po.date_created), po.po_id
) - po.ordered AS cumulative_before
FROM public.purchase_orders po
WHERE po.status != 'canceled'
AND NOT EXISTS (
SELECT 1 FROM StalePOLines s
WHERE s.po_id = po.po_id AND s.pid = po.pid
)
),
-- Total received per (product, supplier) across all receivings.
SupplierReceived AS (
SELECT pid, supplier_id, SUM(qty_each) AS total_received
FROM public.receivings
WHERE status IN ('partial_received', 'full_received', 'paid')
GROUP BY pid, supplier_id
),
-- FIFO allocation: receivings fill oldest POs first per (pid, supplier).
-- Only open PO lines are reported; closed POs just absorb receivings.
OnOrderInfo AS (
SELECT
pid,
SUM(ordered) AS on_order_qty,
SUM(ordered * po_cost_price) AS on_order_cost,
MIN(expected_date) AS earliest_expected_date
FROM public.purchase_orders
WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started')
AND status NOT IN ('canceled', 'done')
GROUP BY pid
po.pid,
SUM(GREATEST(0,
po.ordered - GREATEST(0, LEAST(po.ordered,
COALESCE(sr.total_received, 0) - po.cumulative_before
))
)) AS on_order_qty,
SUM(GREATEST(0,
po.ordered - GREATEST(0, LEAST(po.ordered,
COALESCE(sr.total_received, 0) - po.cumulative_before
))
) * po.po_cost_price) AS on_order_cost,
MIN(po.expected_date) FILTER (WHERE
po.ordered > GREATEST(0, LEAST(po.ordered,
COALESCE(sr.total_received, 0) - po.cumulative_before
))
) AS earliest_expected_date
FROM POFifo po
LEFT JOIN SupplierReceived sr ON sr.pid = po.pid AND sr.supplier_id = po.supplier_id
WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent',
'electronically_ready_send', 'receiving_started')
GROUP BY po.pid
),
HistoricalDates AS (
-- Note: Calculating these MIN/MAX values hourly can be slow on large tables.
@@ -142,6 +198,17 @@ BEGIN
FROM public.daily_product_snapshots
GROUP BY pid
),
BeginningStock AS (
-- Get stock level from 30 days ago for sell-through calculation.
-- Uses the closest available snapshot if exact date is missing (activity-only snapshots).
SELECT DISTINCT ON (pid)
pid,
eod_stock_quantity AS beginning_stock_30d
FROM public.daily_product_snapshots
WHERE snapshot_date <= _current_date - INTERVAL '30 days'
AND snapshot_date >= _current_date - INTERVAL '37 days'
ORDER BY pid, snapshot_date DESC
),
FirstPeriodMetrics AS (
SELECT
pid,
@@ -204,17 +271,30 @@ BEGIN
GROUP BY pid
),
DemandVariability AS (
-- Calculate variance and standard deviation of daily sales
-- Calculate variance and standard deviation of daily sales over the full 30-day window
-- including zero-sales days (not just activity days) for accurate variability metrics.
-- Uses algebraic equivalents to avoid expensive CROSS JOIN with generate_series.
-- For N=30 total days, k active days, sum S, sum_sq SS:
-- mean = S/N, variance = (SS/N) - (S/N)^2 (population variance over all N days)
SELECT
pid,
COUNT(*) AS days_with_data,
AVG(units_sold) AS avg_daily_sales,
VARIANCE(units_sold) AS sales_variance,
STDDEV(units_sold) AS sales_std_dev,
-- Coefficient of variation
CASE
WHEN AVG(units_sold) > 0 THEN STDDEV(units_sold) / AVG(units_sold)
ELSE NULL
SUM(units_sold)::numeric / 30.0 AS avg_daily_sales,
CASE WHEN SUM(units_sold) > 0 THEN
(SUM(units_sold::numeric * units_sold::numeric) / 30.0)
- (SUM(units_sold)::numeric / 30.0) * (SUM(units_sold)::numeric / 30.0)
END AS sales_variance,
CASE WHEN SUM(units_sold) > 0 THEN
(|/ GREATEST(0,
(SUM(units_sold::numeric * units_sold::numeric) / 30.0)
- (SUM(units_sold)::numeric / 30.0) * (SUM(units_sold)::numeric / 30.0)
))::numeric
END AS sales_std_dev,
CASE WHEN SUM(units_sold) > 0 THEN
((|/ GREATEST(0,
(SUM(units_sold::numeric * units_sold::numeric) / 30.0)
- (SUM(units_sold)::numeric / 30.0) * (SUM(units_sold)::numeric / 30.0)
)) / (SUM(units_sold)::numeric / 30.0))::numeric
END AS sales_cv
FROM public.daily_product_snapshots
WHERE snapshot_date >= _current_date - INTERVAL '29 days'
@@ -242,14 +322,51 @@ BEGIN
GROUP BY pid
),
SeasonalityAnalysis AS (
-- Simple seasonality detection
-- Set-based seasonality detection (replaces per-product function calls)
-- Computes monthly CV and peak-to-average ratio across the last 12 months
SELECT
p.pid,
sp.seasonal_pattern,
sp.seasonality_index,
sp.peak_season
FROM products p
CROSS JOIN LATERAL detect_seasonal_pattern(p.pid) sp
pid,
CASE
WHEN monthly_cv > 0.5 AND seasonality_index > 150 THEN 'strong'
WHEN monthly_cv > 0.3 AND seasonality_index > 120 THEN 'moderate'
ELSE 'none'
END::varchar AS seasonal_pattern,
CASE
WHEN monthly_cv > 0.3 AND seasonality_index > 120 THEN seasonality_index
ELSE 100::numeric
END AS seasonality_index,
CASE
WHEN monthly_cv > 0.3 AND seasonality_index > 120
THEN TRIM(TO_CHAR(TO_DATE(peak_month::text, 'MM'), 'Month'))
ELSE NULL
END::varchar AS peak_season
FROM (
SELECT
pid,
CASE WHEN overall_avg > 0 AND monthly_stddev IS NOT NULL
THEN monthly_stddev / overall_avg ELSE 0 END AS monthly_cv,
CASE WHEN overall_avg > 0
THEN ROUND((max_month_avg / overall_avg * 100)::numeric, 2)
ELSE 100 END AS seasonality_index,
peak_month
FROM (
SELECT
ms.pid,
AVG(ms.month_avg) AS overall_avg,
STDDEV(ms.month_avg) AS monthly_stddev,
MAX(ms.month_avg) AS max_month_avg,
(ARRAY_AGG(ms.mo ORDER BY ms.month_avg DESC))[1] AS peak_month
FROM (
SELECT pid, EXTRACT(MONTH FROM snapshot_date)::int AS mo, AVG(units_sold) AS month_avg
FROM daily_product_snapshots
WHERE snapshot_date >= CURRENT_DATE - INTERVAL '365 days'
AND units_sold > 0
GROUP BY pid, EXTRACT(MONTH FROM snapshot_date)
) ms
GROUP BY ms.pid
HAVING COUNT(*) >= 3 -- Need at least 3 months for meaningful seasonality
) agg
) classified
)
-- Final UPSERT into product_metrics
INSERT INTO public.product_metrics (
@@ -257,7 +374,7 @@ BEGIN
barcode, harmonized_tariff_code, vendor_reference, notions_reference, line, subline, artist,
moq, rating, reviews, weight, length, width, height, country_of_origin, location,
baskets, notifies, preorder_count, notions_inv_count,
current_price, current_regular_price, current_cost_price, current_landing_cost_price,
current_price, current_regular_price, current_cost_price,
current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
date_created, date_first_received, date_last_received, date_first_sold, date_last_sold, age_days,
@@ -295,7 +412,7 @@ BEGIN
ci.barcode, ci.harmonized_tariff_code, ci.vendor_reference, ci.notions_reference, ci.line, ci.subline, ci.artist,
ci.moq, ci.rating, ci.reviews, ci.weight, ci.length, ci.width, ci.height, ci.country_of_origin, ci.location,
ci.baskets, ci.notifies, ci.preorder_count, ci.notions_inv_count,
ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost,
ci.current_price, ci.current_regular_price, ci.current_cost_price,
ci.current_stock, ci.current_stock * ci.current_effective_cost, ci.current_stock * ci.current_price, ci.current_stock * ci.current_regular_price,
COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00), COALESCE(ooi.on_order_qty, 0) * ci.current_price, ooi.earliest_expected_date,
ci.created_at::date, COALESCE(ci.first_received::date, hd.date_first_received_calc), hd.date_last_received_calc, hd.date_first_sold, COALESCE(ci.date_last_sold, hd.max_order_date),
@@ -321,9 +438,9 @@ BEGIN
(GREATEST(0, ci.historical_total_sold - COALESCE(lr.lifetime_units_from_orders, 0)) *
COALESCE(
-- Use oldest known price from snapshots as proxy
(SELECT revenue_7d / NULLIF(sales_7d, 0)
(SELECT net_revenue / NULLIF(units_sold, 0)
FROM daily_product_snapshots
WHERE pid = ci.pid AND sales_7d > 0
WHERE pid = ci.pid AND units_sold > 0
ORDER BY snapshot_date ASC
LIMIT 1),
ci.current_price
@@ -353,10 +470,10 @@ BEGIN
(sa.stockout_days_30d / 30.0) * 100 AS stockout_rate_30d,
sa.gross_regular_revenue_30d - sa.gross_revenue_30d AS markdown_30d,
((sa.gross_regular_revenue_30d - sa.gross_revenue_30d) / NULLIF(sa.gross_regular_revenue_30d, 0)) * 100 AS markdown_rate_30d,
-- Fix sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received)
-- Approximating beginning inventory as current stock + units sold - units received
-- Sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received)
-- Uses actual snapshot from 30 days ago as beginning stock, falls back to avg_stock_units_30d
(sa.sales_30d / NULLIF(
ci.current_stock + sa.sales_30d + sa.returns_units_30d - sa.received_qty_30d,
COALESCE(bs.beginning_stock_30d, sa.avg_stock_units_30d::int, 0) + sa.received_qty_30d,
0
)) * 100 AS sell_through_30d,
@@ -505,6 +622,7 @@ BEGIN
LEFT JOIN PreviousPeriodMetrics ppm ON ci.pid = ppm.pid
LEFT JOIN DemandVariability dv ON ci.pid = dv.pid
LEFT JOIN ServiceLevels sl ON ci.pid = sl.pid
LEFT JOIN BeginningStock bs ON ci.pid = bs.pid
LEFT JOIN SeasonalityAnalysis season ON ci.pid = season.pid
WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL -- Exclude products explicitly marked
@@ -514,7 +632,7 @@ BEGIN
barcode = EXCLUDED.barcode, harmonized_tariff_code = EXCLUDED.harmonized_tariff_code, vendor_reference = EXCLUDED.vendor_reference, notions_reference = EXCLUDED.notions_reference, line = EXCLUDED.line, subline = EXCLUDED.subline, artist = EXCLUDED.artist,
moq = EXCLUDED.moq, rating = EXCLUDED.rating, reviews = EXCLUDED.reviews, weight = EXCLUDED.weight, length = EXCLUDED.length, width = EXCLUDED.width, height = EXCLUDED.height, country_of_origin = EXCLUDED.country_of_origin, location = EXCLUDED.location,
baskets = EXCLUDED.baskets, notifies = EXCLUDED.notifies, preorder_count = EXCLUDED.preorder_count, notions_inv_count = EXCLUDED.notions_inv_count,
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price,
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price,
current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross,
on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date,
date_created = EXCLUDED.date_created, date_first_received = EXCLUDED.date_first_received, date_last_received = EXCLUDED.date_last_received, date_first_sold = EXCLUDED.date_first_sold, date_last_sold = EXCLUDED.date_last_sold, age_days = EXCLUDED.age_days,
@@ -567,11 +685,26 @@ BEGIN
product_metrics.replenishment_units IS DISTINCT FROM EXCLUDED.replenishment_units OR
product_metrics.stock_cover_in_days IS DISTINCT FROM EXCLUDED.stock_cover_in_days OR
product_metrics.yesterday_sales IS DISTINCT FROM EXCLUDED.yesterday_sales OR
-- Check a few other important fields that might change
product_metrics.date_last_sold IS DISTINCT FROM EXCLUDED.date_last_sold OR
product_metrics.earliest_expected_date IS DISTINCT FROM EXCLUDED.earliest_expected_date OR
product_metrics.lifetime_sales IS DISTINCT FROM EXCLUDED.lifetime_sales OR
product_metrics.lifetime_revenue_quality IS DISTINCT FROM EXCLUDED.lifetime_revenue_quality
product_metrics.lifetime_revenue_quality IS DISTINCT FROM EXCLUDED.lifetime_revenue_quality OR
-- Derived metrics that can change even when source fields don't
product_metrics.profit_30d IS DISTINCT FROM EXCLUDED.profit_30d OR
product_metrics.cogs_30d IS DISTINCT FROM EXCLUDED.cogs_30d OR
product_metrics.margin_30d IS DISTINCT FROM EXCLUDED.margin_30d OR
product_metrics.stockout_days_30d IS DISTINCT FROM EXCLUDED.stockout_days_30d OR
product_metrics.sell_through_30d IS DISTINCT FROM EXCLUDED.sell_through_30d OR
-- Growth and variability metrics
product_metrics.sales_growth_30d_vs_prev IS DISTINCT FROM EXCLUDED.sales_growth_30d_vs_prev OR
product_metrics.revenue_growth_30d_vs_prev IS DISTINCT FROM EXCLUDED.revenue_growth_30d_vs_prev OR
product_metrics.demand_pattern IS DISTINCT FROM EXCLUDED.demand_pattern OR
product_metrics.seasonal_pattern IS DISTINCT FROM EXCLUDED.seasonal_pattern OR
product_metrics.seasonality_index IS DISTINCT FROM EXCLUDED.seasonality_index OR
product_metrics.service_level_30d IS DISTINCT FROM EXCLUDED.service_level_30d OR
product_metrics.fill_rate_30d IS DISTINCT FROM EXCLUDED.fill_rate_30d OR
-- Time-based safety net: always update if more than 1 day stale
product_metrics.last_calculated < NOW() - INTERVAL '1 day'
;
-- Update the status table with the timestamp from the START of this run

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,5 @@
const express = require('express');
const router = express.Router();
const { Pool } = require('pg'); // Assuming pg driver
// --- Configuration & Helpers ---
@@ -44,7 +43,6 @@ const COLUMN_MAP = {
currentPrice: 'pm.current_price',
currentRegularPrice: 'pm.current_regular_price',
currentCostPrice: 'pm.current_cost_price',
currentLandingCostPrice: 'pm.current_landing_cost_price',
currentStock: 'pm.current_stock',
currentStockCost: 'pm.current_stock_cost',
currentStockRetail: 'pm.current_stock_retail',
@@ -177,7 +175,7 @@ const COLUMN_MAP = {
const COLUMN_TYPES = {
// Numeric columns (use numeric operators and sorting)
numeric: [
'pid', 'currentPrice', 'currentRegularPrice', 'currentCostPrice', 'currentLandingCostPrice',
'pid', 'currentPrice', 'currentRegularPrice', 'currentCostPrice',
'currentStock', 'currentStockCost', 'currentStockRetail', 'currentStockGross',
'onOrderQty', 'onOrderCost', 'onOrderRetail', 'ageDays',
'sales7d', 'revenue7d', 'sales14d', 'revenue14d', 'sales30d', 'revenue30d',
@@ -255,32 +253,98 @@ const SPECIAL_SORT_COLUMNS = {
};
// Status priority for sorting (lower number = higher priority)
// Values must match what's stored in the DB status column
const STATUS_PRIORITY = {
'Critical': 1,
'At Risk': 2,
'Reorder': 3,
'Overstocked': 4,
'Reorder Soon': 3,
'Overstock': 4,
'Healthy': 5,
'New': 6
// Any other status will be sorted alphabetically after these
};
// Get database column name from frontend column name
// Returns null for unknown keys so callers can skip them
function getDbColumn(frontendColumn) {
return COLUMN_MAP[frontendColumn] || 'pm.title'; // Default to title if not found
return COLUMN_MAP[frontendColumn] || null;
}
// Get column type for proper sorting
// Get column type by searching through the COLUMN_TYPES arrays
function getColumnType(frontendColumn) {
return COLUMN_TYPES[frontendColumn] || 'string';
if (COLUMN_TYPES.numeric.includes(frontendColumn)) return 'numeric';
if (COLUMN_TYPES.date.includes(frontendColumn)) return 'date';
if (COLUMN_TYPES.boolean.includes(frontendColumn)) return 'boolean';
return 'string';
}
// --- Route Handlers ---
// GET /metrics/summary - Aggregate KPI summary for the current view
router.get('/summary', async (req, res) => {
const pool = req.app.locals.pool;
try {
// Build WHERE clause from same filters as main list endpoint
const conditions = ['pm.is_visible = true', 'pm.is_replenishable = true'];
const params = [];
let paramCounter = 1;
// Handle showNonReplenishable
if (req.query.showNonReplenishable === 'true') {
// Remove the is_replenishable condition
conditions.pop();
}
// Handle showInvisible
if (req.query.showInvisible === 'true') {
conditions.shift(); // Remove is_visible condition
}
// Handle stock_status filter
if (req.query.stock_status) {
conditions.push(`pm.status = $${paramCounter++}`);
params.push(req.query.stock_status);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const sql = `
SELECT
COUNT(*)::int AS total_products,
COALESCE(SUM(pm.current_stock_cost), 0)::numeric(15,2) AS total_stock_value,
COALESCE(SUM(pm.current_stock_retail), 0)::numeric(15,2) AS total_stock_retail,
COUNT(*) FILTER (WHERE pm.status IN ('Critical', 'Reorder Soon'))::int AS needs_reorder_count,
COALESCE(SUM(pm.replenishment_cost) FILTER (WHERE pm.replenishment_units > 0), 0)::numeric(15,2) AS total_replenishment_cost,
COALESCE(SUM(pm.replenishment_units) FILTER (WHERE pm.replenishment_units > 0), 0)::int AS total_replenishment_units,
COALESCE(SUM(pm.overstocked_cost) FILTER (WHERE pm.overstocked_units > 0), 0)::numeric(15,2) AS total_overstock_value,
COALESCE(SUM(pm.overstocked_units) FILTER (WHERE pm.overstocked_units > 0), 0)::int AS total_overstock_units,
COALESCE(SUM(pm.on_order_qty), 0)::int AS total_on_order_units,
COALESCE(SUM(pm.on_order_cost), 0)::numeric(15,2) AS total_on_order_cost,
COALESCE(AVG(pm.stock_cover_in_days) FILTER (WHERE pm.stock_cover_in_days IS NOT NULL AND pm.current_stock > 0), 0)::numeric(10,1) AS avg_stock_cover_days,
COUNT(*) FILTER (WHERE pm.current_stock = 0)::int AS out_of_stock_count,
COALESCE(SUM(pm.forecast_lost_revenue) FILTER (WHERE pm.forecast_lost_revenue > 0), 0)::numeric(15,2) AS total_lost_revenue,
COALESCE(SUM(pm.forecast_lost_sales_units) FILTER (WHERE pm.forecast_lost_sales_units > 0), 0)::int AS total_lost_sales_units,
COUNT(*) FILTER (WHERE pm.status = 'Critical')::int AS critical_count,
COUNT(*) FILTER (WHERE pm.status = 'Reorder Soon')::int AS reorder_count,
COUNT(*) FILTER (WHERE pm.status = 'At Risk')::int AS at_risk_count,
COUNT(*) FILTER (WHERE pm.status = 'Overstock')::int AS overstock_count,
COUNT(*) FILTER (WHERE pm.status = 'Healthy')::int AS healthy_count,
COUNT(*) FILTER (WHERE pm.status = 'New')::int AS new_count
FROM public.product_metrics pm
${whereClause}
`;
const { rows } = await pool.query(sql, params);
res.json(rows[0]);
} catch (error) {
console.error('Error fetching metrics summary:', error);
res.status(500).json({ error: 'Failed to fetch metrics summary.' });
}
});
// GET /metrics/filter-options - Provide distinct values for filter dropdowns
router.get('/filter-options', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /metrics/filter-options');
try {
const [vendorRes, brandRes, abcClassRes] = await Promise.all([
pool.query(`SELECT DISTINCT vendor FROM public.product_metrics WHERE vendor IS NOT NULL AND vendor <> '' ORDER BY vendor`),
@@ -304,7 +368,6 @@ router.get('/filter-options', async (req, res) => {
// GET /metrics/ - List all product metrics with filtering, sorting, pagination
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /metrics received query:', req.query);
try {
// --- Pagination ---
@@ -317,11 +380,9 @@ router.get('/', async (req, res) => {
// --- Sorting ---
const sortQueryKey = req.query.sort || 'title'; // Default sort field key
const dbColumn = getDbColumn(sortQueryKey);
const sortDbColumn = getDbColumn(sortQueryKey) || 'pm.title';
const columnType = getColumnType(sortQueryKey);
console.log(`Sorting request: ${sortQueryKey} -> ${dbColumn} (${columnType})`);
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
// Always put nulls last regardless of sort direction or column type
@@ -332,29 +393,29 @@ router.get('/', async (req, res) => {
if (SPECIAL_SORT_COLUMNS[sortQueryKey] === 'abs') {
// Sort by absolute value for columns where negative values matter
orderByClause = `ABS(${dbColumn}::numeric) ${sortDirection} ${nullsOrder}`;
} else if (columnType === 'number' || SPECIAL_SORT_COLUMNS[sortQueryKey] === true) {
orderByClause = `ABS(${sortDbColumn}::numeric) ${sortDirection} ${nullsOrder}`;
} else if (columnType === 'numeric' || SPECIAL_SORT_COLUMNS[sortQueryKey] === true) {
// For numeric columns, cast to numeric to ensure proper sorting
orderByClause = `${dbColumn}::numeric ${sortDirection} ${nullsOrder}`;
orderByClause = `${sortDbColumn}::numeric ${sortDirection} ${nullsOrder}`;
} else if (columnType === 'date') {
// For date columns, cast to timestamp to ensure proper sorting
orderByClause = `CASE WHEN ${dbColumn} IS NULL THEN 1 ELSE 0 END, ${dbColumn}::timestamp ${sortDirection}`;
} else if (columnType === 'status' || SPECIAL_SORT_COLUMNS[sortQueryKey] === 'priority') {
orderByClause = `CASE WHEN ${sortDbColumn} IS NULL THEN 1 ELSE 0 END, ${sortDbColumn}::timestamp ${sortDirection}`;
} else if (SPECIAL_SORT_COLUMNS[sortQueryKey] === 'priority') {
// Special handling for status column, using priority for known statuses
orderByClause = `
CASE WHEN ${dbColumn} IS NULL THEN 999
WHEN ${dbColumn} = 'Critical' THEN 1
WHEN ${dbColumn} = 'At Risk' THEN 2
WHEN ${dbColumn} = 'Reorder' THEN 3
WHEN ${dbColumn} = 'Overstocked' THEN 4
WHEN ${dbColumn} = 'Healthy' THEN 5
WHEN ${dbColumn} = 'New' THEN 6
CASE WHEN ${sortDbColumn} IS NULL THEN 999
WHEN ${sortDbColumn} = 'Critical' THEN 1
WHEN ${sortDbColumn} = 'At Risk' THEN 2
WHEN ${sortDbColumn} = 'Reorder Soon' THEN 3
WHEN ${sortDbColumn} = 'Overstock' THEN 4
WHEN ${sortDbColumn} = 'Healthy' THEN 5
WHEN ${sortDbColumn} = 'New' THEN 6
ELSE 100
END ${sortDirection} ${nullsOrder},
${dbColumn} ${sortDirection}`;
${sortDbColumn} ${sortDirection}`;
} else {
// For string and boolean columns, no special casting needed
orderByClause = `CASE WHEN ${dbColumn} IS NULL THEN 1 ELSE 0 END, ${dbColumn} ${sortDirection}`;
orderByClause = `CASE WHEN ${sortDbColumn} IS NULL THEN 1 ELSE 0 END, ${sortDbColumn} ${sortDirection}`;
}
// --- Filtering ---
@@ -389,26 +450,26 @@ router.get('/', async (req, res) => {
let operator = '='; // Default operator
let value = req.query[key];
// Check for operator suffixes (e.g., sales30d_gt, title_like)
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
// Check for operator suffixes (e.g., sales30d_gt, title_ilike, isVisible_is_true)
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|starts_with|ends_with|not_contains|between|in|is_empty|is_not_empty|is_true|is_false)$/);
if (operatorMatch) {
filterKey = operatorMatch[1]; // e.g., "sales30d"
operator = operatorMatch[2]; // e.g., "gt"
}
// Get the database column for this filter key
const dbColumn = getDbColumn(filterKey);
const filterDbColumn = getDbColumn(filterKey);
const valueType = getColumnType(filterKey);
if (!dbColumn) {
if (!filterDbColumn) {
console.warn(`Invalid filter key ignored: ${key}`);
continue; // Skip if the key doesn't map to a known column
}
// --- Build WHERE clause fragment ---
let needsParam = true; // Declared outside try so catch can access it
try {
let conditionFragment = '';
let needsParam = true; // Most operators need a parameter
switch (operator.toLowerCase()) {
case 'eq': operator = '='; break;
@@ -417,48 +478,65 @@ router.get('/', async (req, res) => {
case 'gte': operator = '>='; break;
case 'lt': operator = '<'; break;
case 'lte': operator = '<='; break;
case 'like': operator = 'LIKE'; value = `%${value}%`; break; // Add wildcards for LIKE
case 'ilike': operator = 'ILIKE'; value = `%${value}%`; break; // Add wildcards for ILIKE
case 'like': operator = 'ILIKE'; value = `%${value}%`; break;
case 'ilike': operator = 'ILIKE'; value = `%${value}%`; break;
case 'starts_with': operator = 'ILIKE'; value = `${value}%`; break;
case 'ends_with': operator = 'ILIKE'; value = `%${value}`; break;
case 'not_contains': operator = 'NOT ILIKE'; value = `%${value}%`; break;
case 'is_empty':
conditionFragment = `(${filterDbColumn} IS NULL OR ${filterDbColumn}::text = '')`;
needsParam = false;
break;
case 'is_not_empty':
conditionFragment = `(${filterDbColumn} IS NOT NULL AND ${filterDbColumn}::text <> '')`;
needsParam = false;
break;
case 'is_true':
conditionFragment = `${filterDbColumn} = true`;
needsParam = false;
break;
case 'is_false':
conditionFragment = `${filterDbColumn} = false`;
needsParam = false;
break;
case 'between':
const [val1, val2] = String(value).split(',');
if (val1 !== undefined && val2 !== undefined) {
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
conditionFragment = `${filterDbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
needsParam = false; // Params added manually
needsParam = false;
} else {
console.warn(`Invalid 'between' value for ${key}: ${value}`);
continue; // Skip this filter
continue;
}
break;
case 'in':
const inValues = String(value).split(',');
if (inValues.length > 0) {
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
conditionFragment = `${dbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType))); // Add all parsed values
needsParam = false; // Params added manually
conditionFragment = `${filterDbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType)));
needsParam = false;
} else {
console.warn(`Invalid 'in' value for ${key}: ${value}`);
continue; // Skip this filter
continue;
}
break;
// Add other operators as needed (IS NULL, IS NOT NULL, etc.)
case '=': // Keep default '='
default: operator = '='; break; // Ensure default is handled
case '=':
default: operator = '='; break;
}
if (needsParam) {
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
conditionFragment = `${filterDbColumn} ${operator} $${paramCounter++}`;
params.push(parseValue(value, valueType));
}
if (conditionFragment) {
conditions.push(`(${conditionFragment})`); // Wrap condition in parentheses
conditions.push(`(${conditionFragment})`);
}
} catch (parseError) {
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
// Decrement counter if param wasn't actually used due to error
if (needsParam) paramCounter--;
}
}
@@ -466,13 +544,8 @@ router.get('/', async (req, res) => {
// --- Construct and Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Debug log of conditions and parameters
console.log('Constructed WHERE conditions:', conditions);
console.log('Parameters:', params);
// Count Query
const countSql = `SELECT COUNT(*) AS total FROM public.product_metrics pm ${whereClause}`;
console.log('Executing Count Query:', countSql, params);
const countPromise = pool.query(countSql, params);
// Data Query (Select all columns from metrics table for now)
@@ -484,16 +557,6 @@ router.get('/', async (req, res) => {
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`;
const dataParams = [...params, limit, offset];
// Log detailed query information for debugging
console.log('Executing Data Query:');
console.log(' - Sort Column:', dbColumn);
console.log(' - Column Type:', columnType);
console.log(' - Sort Direction:', sortDirection);
console.log(' - Order By Clause:', orderByClause);
console.log(' - Full SQL:', dataSql);
console.log(' - Parameters:', dataParams);
const dataPromise = pool.query(dataSql, dataParams);
// Execute queries in parallel
@@ -501,7 +564,6 @@ router.get('/', async (req, res) => {
const total = parseInt(countResult.rows[0].total, 10);
const metrics = dataResult.rows;
console.log(`Total: ${total}, Fetched: ${metrics.length} for page ${page}`);
// --- Respond ---
res.json({
@@ -535,7 +597,6 @@ router.get('/:pid', async (req, res) => {
return res.status(400).json({ error: 'Invalid Product ID.' });
}
console.log(`GET /metrics/${pid}`);
try {
const { rows } = await pool.query(
`SELECT * FROM public.product_metrics WHERE pid = $1`,
@@ -543,11 +604,8 @@ router.get('/:pid', async (req, res) => {
);
if (rows.length === 0) {
console.log(`Metrics not found for PID: ${pid}`);
return res.status(404).json({ error: 'Metrics not found for this product.' });
}
console.log(`Metrics found for PID: ${pid}`);
// Data is pre-calculated, return the first (only) row
res.json(rows[0]);
@@ -566,7 +624,7 @@ function parseValue(value, type) {
if (value === null || value === undefined || value === '') return null; // Allow empty strings? Or handle differently?
switch (type) {
case 'number':
case 'numeric':
const num = parseFloat(value);
if (isNaN(num)) throw new Error(`Invalid number format: "${value}"`);
return num;

View File

@@ -0,0 +1,724 @@
const express = require('express');
const router = express.Router();
// Shared CTE fragment for the reference date.
// Uses MAX(last_calculated) from product_metrics so time-relative logic works
// correctly even when the local data snapshot is behind real-time.
const REF_DATE_CTE = `
ref AS (SELECT COALESCE(MAX(last_calculated), NOW()) as d FROM product_metrics)
`;
// Category definitions matching production website logic:
//
// NEW: date_online within 31 days (matches prod's date_ol), NOT preorder
// PRE-ORDER: preorder_count > 0, NOT new
// CLEARANCE: (regular_price - price) / regular_price >= 0.35 (matches prod's 35% clearance threshold)
// DAILY DEALS: product_daily_deals table
// BACK IN STOCK: date_last_received > date_first_received, received within 14d,
// first received > 30d ago, excludes new products (prod excludes datein < 30d)
// BESTSELLERS: shop_score > 20 + in stock + recent sales (matches prod's /shop/hot page)
//
// Mutual exclusivity:
// - New and Pre-order are exclusive: if preorder_count > 0, it's preorder not new
// - Back in stock excludes new products and preorder products
// - Clearance is independent (a bestseller can also be clearance)
const CATEGORY_FILTERS = {
new: "AND is_new = true",
preorder: "AND is_preorder = true",
clearance: "AND is_clearance = true",
daily_deals: "AND is_daily_deal = true",
back_in_stock: "AND is_back_in_stock = true",
bestsellers: "AND shop_score > 20 AND COALESCE(current_stock, 0) > 0 AND COALESCE(sales_30d, 0) > 0",
never_featured: "AND times_featured IS NULL AND line_last_featured_at IS NULL",
no_interest: "AND COALESCE(total_sold, 0) = 0 AND COALESCE(current_stock, 0) > 0 AND COALESCE(date_online, product_created_at) <= CURRENT_DATE - INTERVAL '30 days'",
};
function buildScoredCTE({ forCount = false } = {}) {
// forCount=true returns minimal columns for COUNT(*)
const selectColumns = forCount ? `
p.pid,
p.created_at as product_created_at,
p.date_online,
p.shop_score,
p.preorder_count,
p.price,
p.regular_price,
p.total_sold,
p.line,
pm.current_stock,
pm.on_order_qty,
pm.sales_30d,
pm.sales_7d,
pm.date_last_received,
pm.date_first_received,
nh.times_featured,
nh.last_featured_at,
lh.line_last_featured_at,
dd.deal_id,
dd.deal_price
` : `
p.pid,
p.title,
p.sku,
p.brand,
p.vendor,
p.price,
p.regular_price,
p.shop_score,
p.image_175 as image,
p.permalink,
p.stock_quantity,
p.preorder_count,
p.tags,
p.categories,
p.line,
p.created_at as product_created_at,
p.date_online,
p.first_received,
p.date_last_sold,
p.total_sold,
p.baskets,
p.notifies,
pm.sales_7d,
pm.sales_30d,
pm.revenue_30d,
pm.current_stock,
pm.on_order_qty,
pm.abc_class,
pm.date_first_received,
pm.date_last_received,
pm.sales_velocity_daily,
pm.sells_out_in_days,
pm.sales_growth_30d_vs_prev,
pm.margin_30d,
-- Direct product feature history
nh.times_featured,
nh.last_featured_at,
EXTRACT(DAY FROM ref.d - nh.last_featured_at)::int as days_since_featured,
-- Line-level feature history
lh.line_products_featured,
lh.line_total_features,
lh.line_last_featured_at,
lh.line_products_featured_30d,
lh.line_products_featured_7d,
ls.line_product_count,
EXTRACT(DAY FROM ref.d - lh.line_last_featured_at)::int as line_days_since_featured,
COALESCE(nh.last_featured_at, lh.line_last_featured_at) as effective_last_featured,
EXTRACT(DAY FROM ref.d - COALESCE(nh.last_featured_at, lh.line_last_featured_at))::int as effective_days_since_featured,
EXTRACT(DAY FROM ref.d - COALESCE(p.date_online, p.created_at))::int as age_days
`;
return `
${REF_DATE_CTE},
newsletter_history AS (
SELECT
pid,
COUNT(*) as times_featured,
MAX(sent_at) as last_featured_at,
MIN(sent_at) as first_featured_at
FROM klaviyo_campaign_products
GROUP BY pid
),
line_history AS (
SELECT
p2.line,
COUNT(DISTINCT kcp.pid) as line_products_featured,
COUNT(*) as line_total_features,
MAX(kcp.sent_at) as line_last_featured_at,
COUNT(DISTINCT kcp.pid) FILTER (
WHERE kcp.sent_at > (SELECT d FROM ref) - INTERVAL '30 days'
) as line_products_featured_30d,
COUNT(DISTINCT kcp.pid) FILTER (
WHERE kcp.sent_at > (SELECT d FROM ref) - INTERVAL '7 days'
) as line_products_featured_7d
FROM products p2
JOIN klaviyo_campaign_products kcp ON kcp.pid = p2.pid
WHERE p2.line IS NOT NULL AND p2.line != ''
GROUP BY p2.line
),
line_sizes AS (
SELECT line, COUNT(*) as line_product_count
FROM products
WHERE visible = true AND line IS NOT NULL AND line != ''
GROUP BY line
),
scored AS (
SELECT
${selectColumns},
-- === CATEGORY FLAGS (production-accurate, mutually exclusive where needed) ===
-- NEW: date_online within 31 days of reference date, AND not on preorder
-- Uses date_online (prod's date_ol) instead of created_at for accuracy
CASE
WHEN p.preorder_count > 0 THEN false
WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN true
ELSE false
END as is_new,
-- PRE-ORDER: has preorder quantity
CASE
WHEN p.preorder_count > 0 THEN true
ELSE false
END as is_preorder,
-- CLEARANCE: 35%+ discount off regular price (matches prod threshold), price must be > 0
CASE
WHEN p.price > 0 AND p.regular_price > 0 AND p.price < p.regular_price
AND ((p.regular_price - p.price) / p.regular_price * 100) >= 35
THEN true
ELSE false
END as is_clearance,
-- DAILY DEALS: product has an active deal for today
CASE WHEN dd.deal_id IS NOT NULL THEN true ELSE false END as is_daily_deal,
dd.deal_price,
-- DISCOUNT %
CASE
WHEN p.price > 0 AND p.regular_price > 0 AND p.price < p.regular_price
THEN ROUND(((p.regular_price - p.price) / p.regular_price * 100)::numeric, 0)
ELSE 0
END as discount_pct,
CASE WHEN pm.current_stock > 0 AND pm.current_stock <= 5 THEN true ELSE false END as is_low_stock,
-- BACK IN STOCK: restocked product, not new, not preorder
-- Matches prod: date_refill within X days, date_refill > datein,
-- NOT datein within last 30 days (excludes new products)
-- We use date_last_received/date_first_received as our equivalents
CASE
WHEN p.preorder_count > 0 THEN false
WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN false
WHEN pm.date_last_received > ref.d - INTERVAL '14 days'
AND pm.date_last_received > pm.date_first_received
AND pm.date_first_received < ref.d - INTERVAL '30 days'
AND pm.current_stock > 0
THEN true
ELSE false
END as is_back_in_stock,
-- === RECOMMENDATION SCORE ===
(
-- New product boost (first 31 days by date_online, not preorder)
CASE
WHEN p.preorder_count > 0 THEN 0
WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '14 days' THEN 50
WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN 35
ELSE 0
END
-- Pre-order boost
+ CASE WHEN p.preorder_count > 0 THEN 30 ELSE 0 END
-- Clearance boost (scaled by discount depth)
+ CASE
WHEN p.price > 0 AND p.regular_price > 0 AND p.price < p.regular_price
AND ((p.regular_price - p.price) / p.regular_price * 100) >= 35
THEN LEAST(((p.regular_price - p.price) / p.regular_price * 50)::int, 25)
ELSE 0
END
-- Sales velocity boost (prod's "hot" logic: recent purchase count)
+ CASE WHEN COALESCE(pm.sales_7d, 0) >= 5 THEN 15
WHEN COALESCE(pm.sales_7d, 0) >= 2 THEN 10
WHEN COALESCE(pm.sales_7d, 0) >= 1 THEN 5
ELSE 0 END
-- Back in stock boost (only for actual restocks, not new arrivals)
+ CASE
WHEN p.preorder_count = 0
AND COALESCE(p.date_online, p.created_at) <= ref.d - INTERVAL '31 days'
AND pm.date_last_received > ref.d - INTERVAL '14 days'
AND pm.date_last_received > pm.date_first_received
AND pm.date_first_received < ref.d - INTERVAL '30 days'
AND pm.current_stock > 0
THEN 25
ELSE 0
END
-- High interest (baskets + notifies)
+ LEAST((COALESCE(p.baskets, 0) + COALESCE(p.notifies, 0)) / 2, 15)
-- Recency penalty: line-aware effective last featured (tuned for daily sends)
+ CASE
WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) IS NULL THEN 10
WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '2 days' THEN -30
WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '5 days' THEN -15
WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '10 days' THEN -5
ELSE 5
END
-- Over-featured penalty (direct product only, tuned for daily sends)
+ CASE
WHEN COALESCE(nh.times_featured, 0) > 15 THEN -10
WHEN COALESCE(nh.times_featured, 0) > 8 THEN -5
ELSE 0
END
-- Line saturation penalty (uses 7-day window for daily send cadence)
+ CASE
WHEN lh.line_products_featured_7d IS NOT NULL
AND ls.line_product_count IS NOT NULL
AND ls.line_product_count > 0
AND (lh.line_products_featured_7d::float / ls.line_product_count) > 0.7
THEN -10
WHEN lh.line_products_featured_7d IS NOT NULL
AND lh.line_products_featured_7d >= 4
THEN -5
ELSE 0
END
-- Price tier adjustment (deprioritize very low-price items)
+ CASE
WHEN COALESCE(p.price, 0) < 3 THEN -15
WHEN COALESCE(p.price, 0) < 8 THEN -5
WHEN COALESCE(p.price, 0) >= 25 THEN 5
ELSE 0
END
-- ABC class boost
+ CASE WHEN pm.abc_class = 'A' THEN 10
WHEN pm.abc_class = 'B' THEN 5
ELSE 0 END
-- Stock penalty
+ CASE
WHEN COALESCE(pm.current_stock, 0) <= 0 AND COALESCE(p.preorder_count, 0) = 0 THEN -100
WHEN COALESCE(pm.current_stock, 0) <= 2 AND COALESCE(p.preorder_count, 0) = 0 THEN -20
ELSE 0
END
) as score
FROM ref, products p
LEFT JOIN product_metrics pm ON pm.pid = p.pid
LEFT JOIN newsletter_history nh ON nh.pid = p.pid
LEFT JOIN line_history lh ON lh.line = p.line AND p.line IS NOT NULL AND p.line != ''
LEFT JOIN line_sizes ls ON ls.line = p.line AND p.line IS NOT NULL AND p.line != ''
LEFT JOIN product_daily_deals dd ON dd.pid = p.pid AND dd.deal_date = CURRENT_DATE
WHERE p.visible = true
)
`;
}
// GET /api/newsletter/recommendations
router.get('/recommendations', async (req, res) => {
const pool = req.app.locals.pool;
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const offset = (page - 1) * limit;
const category = req.query.category || 'all';
const categoryFilter = CATEGORY_FILTERS[category] || '';
const query = `
WITH ${buildScoredCTE()}
SELECT *
FROM scored
WHERE score > -50
${categoryFilter}
ORDER BY score DESC, COALESCE(sales_7d, 0) DESC
LIMIT $1 OFFSET $2
`;
const countQuery = `
WITH ${buildScoredCTE({ forCount: true })}
SELECT COUNT(*) FROM scored
WHERE score > -50
${categoryFilter}
`;
const [dataResult, countResult] = await Promise.all([
pool.query(query, [limit, offset]),
pool.query(countQuery)
]);
res.json({
products: dataResult.rows,
pagination: {
total: parseInt(countResult.rows[0].count),
pages: Math.ceil(parseInt(countResult.rows[0].count) / limit),
currentPage: page,
limit
}
});
} catch (error) {
console.error('Error fetching newsletter recommendations:', error);
res.status(500).json({ error: 'Failed to fetch newsletter recommendations' });
}
});
// GET /api/newsletter/history/:pid
router.get('/history/:pid', async (req, res) => {
const pool = req.app.locals.pool;
const { pid } = req.params;
try {
const { rows } = await pool.query(`
SELECT campaign_id, campaign_name, sent_at, product_url
FROM klaviyo_campaign_products
WHERE pid = $1
ORDER BY sent_at DESC
`, [pid]);
res.json({ history: rows });
} catch (error) {
console.error('Error fetching newsletter history:', error);
res.status(500).json({ error: 'Failed to fetch newsletter history' });
}
});
// GET /api/newsletter/stats
router.get('/stats', async (req, res) => {
const pool = req.app.locals.pool;
try {
const { rows } = await pool.query(`
WITH ref AS (SELECT COALESCE(MAX(last_calculated), NOW()) as d FROM product_metrics),
featured_pids AS (
SELECT DISTINCT pid FROM klaviyo_campaign_products
),
recent_pids AS (
SELECT DISTINCT pid FROM klaviyo_campaign_products
WHERE sent_at > (SELECT d FROM ref) - INTERVAL '2 days'
)
SELECT
-- Unfeatured new products
(SELECT COUNT(*) FROM products p, ref
WHERE p.visible = true AND p.preorder_count = 0
AND COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days'
AND p.pid NOT IN (SELECT pid FROM featured_pids)
) as unfeatured_new,
-- Back in stock, not yet featured since restock
(SELECT COUNT(*) FROM products p
JOIN product_metrics pm ON pm.pid = p.pid
CROSS JOIN ref
WHERE p.visible = true
AND p.preorder_count = 0
AND COALESCE(p.date_online, p.created_at) <= ref.d - INTERVAL '31 days'
AND pm.date_last_received > ref.d - INTERVAL '14 days'
AND pm.date_last_received > pm.date_first_received
AND pm.date_first_received < ref.d - INTERVAL '30 days'
AND pm.current_stock > 0
AND p.pid NOT IN (
SELECT pid FROM klaviyo_campaign_products
WHERE sent_at > pm.date_last_received
)
) as back_in_stock_ready,
-- High score products available (score 40+, not featured in last 2 days)
(SELECT COUNT(*) FROM (
WITH ${buildScoredCTE({ forCount: true })}
SELECT pid FROM scored
WHERE score >= 40
AND pid NOT IN (SELECT pid FROM recent_pids)
) hs) as high_score_available,
-- Last campaign date
(SELECT MAX(sent_at) FROM klaviyo_campaign_products) as last_campaign_date,
-- Avg days since last featured (across visible in-stock catalog)
(SELECT ROUND(AVG(days)::numeric, 1) FROM (
SELECT EXTRACT(DAY FROM ref.d - MAX(kcp.sent_at))::int as days
FROM products p
CROSS JOIN ref
JOIN klaviyo_campaign_products kcp ON kcp.pid = p.pid
JOIN product_metrics pm ON pm.pid = p.pid
WHERE p.visible = true AND COALESCE(pm.current_stock, 0) > 0
GROUP BY p.pid, ref.d
) avg_calc) as avg_days_since_featured,
-- Never featured (visible, in stock or preorder)
(SELECT COUNT(*) FROM products p
LEFT JOIN product_metrics pm ON pm.pid = p.pid
WHERE p.visible = true
AND (COALESCE(pm.current_stock, 0) > 0 OR p.preorder_count > 0)
AND p.pid NOT IN (SELECT pid FROM featured_pids)
) as never_featured
`);
res.json(rows[0]);
} catch (error) {
console.error('Error fetching newsletter stats:', error);
res.status(500).json({ error: 'Failed to fetch newsletter stats' });
}
});
// GET /api/newsletter/score-breakdown/:pid
// Returns the individual scoring factors for a single product (debug endpoint)
router.get('/score-breakdown/:pid', async (req, res) => {
const pool = req.app.locals.pool;
const { pid } = req.params;
try {
const { rows } = await pool.query(`
WITH ${REF_DATE_CTE},
newsletter_history AS (
SELECT pid, COUNT(*) as times_featured, MAX(sent_at) as last_featured_at
FROM klaviyo_campaign_products GROUP BY pid
),
line_history AS (
SELECT p2.line,
COUNT(DISTINCT kcp.pid) FILTER (WHERE kcp.sent_at > (SELECT d FROM ref) - INTERVAL '7 days') as line_products_featured_7d
FROM products p2
JOIN klaviyo_campaign_products kcp ON kcp.pid = p2.pid
WHERE p2.line IS NOT NULL AND p2.line != ''
GROUP BY p2.line
),
line_sizes AS (
SELECT line, COUNT(*) as line_product_count
FROM products WHERE visible = true AND line IS NOT NULL AND line != '' GROUP BY line
)
SELECT
-- New product boost
CASE
WHEN p.preorder_count > 0 THEN 0
WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '14 days' THEN 50
WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN 35
ELSE 0
END as new_boost,
-- Pre-order boost
CASE WHEN p.preorder_count > 0 THEN 30 ELSE 0 END as preorder_boost,
-- Clearance boost
CASE
WHEN p.price > 0 AND p.regular_price > 0 AND p.price < p.regular_price
AND ((p.regular_price - p.price) / p.regular_price * 100) >= 35
THEN LEAST(((p.regular_price - p.price) / p.regular_price * 50)::int, 25)
ELSE 0
END as clearance_boost,
-- Sales velocity
CASE WHEN COALESCE(pm.sales_7d, 0) >= 5 THEN 15
WHEN COALESCE(pm.sales_7d, 0) >= 2 THEN 10
WHEN COALESCE(pm.sales_7d, 0) >= 1 THEN 5
ELSE 0 END as velocity_boost,
-- Back in stock
CASE
WHEN p.preorder_count = 0
AND COALESCE(p.date_online, p.created_at) <= ref.d - INTERVAL '31 days'
AND pm.date_last_received > ref.d - INTERVAL '14 days'
AND pm.date_last_received > pm.date_first_received
AND pm.date_first_received < ref.d - INTERVAL '30 days'
AND pm.current_stock > 0
THEN 25 ELSE 0
END as back_in_stock_boost,
-- Interest
LEAST((COALESCE(p.baskets, 0) + COALESCE(p.notifies, 0)) / 2, 15) as interest_boost,
-- Recency
CASE
WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) IS NULL THEN 10
WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '2 days' THEN -30
WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '5 days' THEN -15
WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '10 days' THEN -5
ELSE 5
END as recency_adj,
-- Over-featured
CASE
WHEN COALESCE(nh.times_featured, 0) > 15 THEN -10
WHEN COALESCE(nh.times_featured, 0) > 8 THEN -5
ELSE 0
END as over_featured_adj,
-- Line saturation
CASE
WHEN lh2.line_products_featured_7d IS NOT NULL
AND ls.line_product_count IS NOT NULL AND ls.line_product_count > 0
AND (lh2.line_products_featured_7d::float / ls.line_product_count) > 0.7
THEN -10
WHEN lh2.line_products_featured_7d IS NOT NULL AND lh2.line_products_featured_7d >= 4
THEN -5
ELSE 0
END as line_saturation_adj,
-- Price tier
CASE
WHEN COALESCE(p.price, 0) < 3 THEN -15
WHEN COALESCE(p.price, 0) < 8 THEN -5
WHEN COALESCE(p.price, 0) >= 25 THEN 5
ELSE 0
END as price_tier_adj,
-- ABC class
CASE WHEN pm.abc_class = 'A' THEN 10 WHEN pm.abc_class = 'B' THEN 5 ELSE 0 END as abc_boost,
-- Stock penalty
CASE
WHEN COALESCE(pm.current_stock, 0) <= 0 AND COALESCE(p.preorder_count, 0) = 0 THEN -100
WHEN COALESCE(pm.current_stock, 0) <= 2 AND COALESCE(p.preorder_count, 0) = 0 THEN -20
ELSE 0
END as stock_penalty
FROM ref, products p
LEFT JOIN product_metrics pm ON pm.pid = p.pid
LEFT JOIN newsletter_history nh ON nh.pid = p.pid
LEFT JOIN LATERAL (
SELECT MAX(kcp.sent_at) as line_last_featured_at
FROM products p3
JOIN klaviyo_campaign_products kcp ON kcp.pid = p3.pid
WHERE p3.line = p.line AND p.line IS NOT NULL AND p.line != ''
) lh ON true
LEFT JOIN line_history lh2 ON lh2.line = p.line AND p.line IS NOT NULL AND p.line != ''
LEFT JOIN line_sizes ls ON ls.line = p.line AND p.line IS NOT NULL AND p.line != ''
WHERE p.pid = $1
`, [pid]);
if (rows.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
res.json(rows[0]);
} catch (error) {
console.error('Error fetching score breakdown:', error);
res.status(500).json({ error: 'Failed to fetch score breakdown' });
}
});
// GET /api/newsletter/campaigns
// Returns all campaigns with product counts and links
router.get('/campaigns', async (req, res) => {
const pool = req.app.locals.pool;
try {
const [campaignsResult, linksResult, summaryResult] = await Promise.all([
pool.query(`
SELECT
kcp.campaign_id,
kcp.campaign_name,
kcp.sent_at,
COUNT(*) as product_count,
json_agg(json_build_object(
'pid', kcp.pid,
'title', p.title,
'sku', p.sku,
'brand', p.brand,
'line', p.line,
'image', p.image_175,
'product_url', kcp.product_url
) ORDER BY p.brand, p.line, p.title) as products
FROM klaviyo_campaign_products kcp
LEFT JOIN products p ON p.pid = kcp.pid
GROUP BY kcp.campaign_id, kcp.campaign_name, kcp.sent_at
ORDER BY kcp.sent_at DESC
`),
pool.query(`
SELECT campaign_id, campaign_name, sent_at, link_url, link_type
FROM klaviyo_campaign_links
ORDER BY sent_at DESC
`),
pool.query(`
SELECT
COUNT(DISTINCT campaign_id) as total_campaigns,
COUNT(DISTINCT pid) as total_unique_products,
ROUND(COUNT(*)::numeric / NULLIF(COUNT(DISTINCT campaign_id), 0), 1) as avg_products_per_campaign
FROM klaviyo_campaign_products
`)
]);
// Group links by campaign_id
const linksByCampaign = {};
for (const link of linksResult.rows) {
if (!linksByCampaign[link.campaign_id]) linksByCampaign[link.campaign_id] = [];
linksByCampaign[link.campaign_id].push(link);
}
const campaigns = campaignsResult.rows.map(c => ({
...c,
links: linksByCampaign[c.campaign_id] || []
}));
res.json({
campaigns,
summary: summaryResult.rows[0]
});
} catch (error) {
console.error('Error fetching campaigns:', error);
res.status(500).json({ error: 'Failed to fetch campaigns' });
}
});
// GET /api/newsletter/campaigns/products
// Returns product-level aggregate stats across all campaigns
router.get('/campaigns/products', async (req, res) => {
const pool = req.app.locals.pool;
try {
const { rows } = await pool.query(`
SELECT
kcp.pid,
p.title,
p.sku,
p.brand,
p.image_175 as image,
p.permalink,
COUNT(*) as times_featured,
MIN(kcp.sent_at) as first_featured_at,
MAX(kcp.sent_at) as last_featured_at,
EXTRACT(DAY FROM NOW() - MAX(kcp.sent_at))::int as days_since_featured,
EXTRACT(DAY FROM MAX(kcp.sent_at) - MIN(kcp.sent_at))::int as featured_span_days,
CASE WHEN COUNT(*) > 1
THEN ROUND(EXTRACT(DAY FROM MAX(kcp.sent_at) - MIN(kcp.sent_at))::numeric / (COUNT(*) - 1), 1)
ELSE NULL
END as avg_days_between_features,
json_agg(json_build_object(
'campaign_id', kcp.campaign_id,
'campaign_name', kcp.campaign_name,
'sent_at', kcp.sent_at
) ORDER BY kcp.sent_at DESC) as campaigns
FROM klaviyo_campaign_products kcp
LEFT JOIN products p ON p.pid = kcp.pid
GROUP BY kcp.pid, p.title, p.sku, p.brand, p.image_175, p.permalink
ORDER BY COUNT(*) DESC, MAX(kcp.sent_at) DESC
`);
res.json({ products: rows });
} catch (error) {
console.error('Error fetching campaign products:', error);
res.status(500).json({ error: 'Failed to fetch campaign products' });
}
});
// GET /api/newsletter/campaigns/brands
// Returns brand-level aggregate stats across all campaigns
router.get('/campaigns/brands', async (req, res) => {
const pool = req.app.locals.pool;
try {
const { rows } = await pool.query(`
SELECT
COALESCE(p.brand, 'Unknown') as brand,
COUNT(DISTINCT kcp.pid) as product_count,
COUNT(*) as times_featured,
MIN(kcp.sent_at) as first_featured_at,
MAX(kcp.sent_at) as last_featured_at,
EXTRACT(DAY FROM NOW() - MAX(kcp.sent_at))::int as days_since_featured,
CASE WHEN COUNT(DISTINCT kcp.campaign_id) > 1
THEN ROUND(EXTRACT(DAY FROM MAX(kcp.sent_at) - MIN(kcp.sent_at))::numeric / (COUNT(DISTINCT kcp.campaign_id) - 1), 1)
ELSE NULL
END as avg_days_between_features,
json_agg(DISTINCT jsonb_build_object(
'campaign_id', kcp.campaign_id,
'campaign_name', kcp.campaign_name,
'sent_at', kcp.sent_at
)) as campaigns
FROM klaviyo_campaign_products kcp
LEFT JOIN products p ON p.pid = kcp.pid
GROUP BY COALESCE(p.brand, 'Unknown')
ORDER BY COUNT(*) DESC, MAX(kcp.sent_at) DESC
`);
res.json({ brands: rows });
} catch (error) {
console.error('Error fetching campaign brands:', error);
res.status(500).json({ error: 'Failed to fetch campaign brands' });
}
});
// GET /api/newsletter/campaigns/links
// Returns link-level aggregate stats across all campaigns
router.get('/campaigns/links', async (req, res) => {
const pool = req.app.locals.pool;
try {
const { rows } = await pool.query(`
SELECT
link_url,
link_type,
COUNT(*) as times_used,
MIN(sent_at) as first_used_at,
MAX(sent_at) as last_used_at,
EXTRACT(DAY FROM NOW() - MAX(sent_at))::int as days_since_used,
json_agg(DISTINCT campaign_name ORDER BY campaign_name) as campaign_names
FROM klaviyo_campaign_links
GROUP BY link_url, link_type
ORDER BY COUNT(*) DESC, MAX(sent_at) DESC
`);
res.json({ links: rows });
} catch (error) {
console.error('Error fetching campaign links:', error);
res.status(500).json({ error: 'Failed to fetch campaign links' });
}
});
module.exports = router;

View File

@@ -145,7 +145,6 @@ router.get('/', async (req, res) => {
stock: 'p.stock_quantity',
price: 'p.price',
costPrice: 'p.cost_price',
landingCost: 'p.landing_cost_price',
dailySalesAvg: 'pm.daily_sales_avg',
weeklySalesAvg: 'pm.weekly_sales_avg',
monthlySalesAvg: 'pm.monthly_sales_avg',
@@ -621,7 +620,6 @@ router.get('/:id', async (req, res) => {
price: parseFloat(productRows[0].price),
regular_price: parseFloat(productRows[0].regular_price),
cost_price: parseFloat(productRows[0].cost_price),
landing_cost_price: parseFloat(productRows[0].landing_cost_price),
stock_quantity: parseInt(productRows[0].stock_quantity),
moq: parseInt(productRows[0].moq),
uom: parseInt(productRows[0].uom),
@@ -731,32 +729,33 @@ router.get('/:id/time-series', async (req, res) => {
LIMIT 10
`, [id]);
// Get recent purchase orders with detailed status
// Get recent purchase orders with received quantities from the receivings table
const { rows: recentPurchases } = await pool.query(`
SELECT
TO_CHAR(date, 'YYYY-MM-DD') as date,
TO_CHAR(expected_date, 'YYYY-MM-DD') as expected_date,
TO_CHAR(received_date, 'YYYY-MM-DD') as received_date,
po_id,
ordered,
received,
status,
receiving_status,
cost_price,
notes,
TO_CHAR(po.date, 'YYYY-MM-DD') as date,
TO_CHAR(po.expected_date, 'YYYY-MM-DD') as expected_date,
TO_CHAR(MAX(r.received_date), 'YYYY-MM-DD') as received_date,
po.po_id,
po.ordered,
COALESCE(SUM(r.qty_each), 0)::integer as received,
po.status,
po.po_cost_price as cost_price,
po.notes,
CASE
WHEN received_date IS NOT NULL THEN
(received_date - date)
WHEN expected_date < CURRENT_DATE AND status < $2 THEN
(CURRENT_DATE - expected_date)
WHEN MAX(r.received_date) IS NOT NULL THEN
EXTRACT(DAY FROM MAX(r.received_date) - po.date)::integer
WHEN po.expected_date < CURRENT_DATE AND po.status NOT IN ('done', 'canceled') THEN
(CURRENT_DATE - po.expected_date)
ELSE NULL
END as lead_time_days
FROM purchase_orders
WHERE pid = $1
AND status != $3
ORDER BY date DESC
FROM purchase_orders po
LEFT JOIN receivings r ON r.receiving_id = po.po_id AND r.pid = po.pid AND r.status != 'canceled'
WHERE po.pid = $1
AND po.status != 'canceled'
GROUP BY po.id, po.po_id, po.date, po.expected_date, po.ordered, po.status, po.po_cost_price, po.notes
ORDER BY po.date DESC
LIMIT 10
`, [id, PurchaseOrderStatus.ReceivingStarted, PurchaseOrderStatus.Canceled]);
`, [id]);
res.json({
monthly_sales: formattedMonthlySales,
@@ -772,8 +771,7 @@ router.get('/:id/time-series', async (req, res) => {
...po,
ordered: parseInt(po.ordered),
received: parseInt(po.received),
status: parseInt(po.status),
receiving_status: parseInt(po.receiving_status),
status: po.status, // Text-based status (e.g., 'done', 'ordered', 'receiving_started')
cost_price: parseFloat(po.cost_price),
lead_time_days: po.lead_time_days ? parseInt(po.lead_time_days) : null
}))
@@ -784,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;

View File

@@ -1185,4 +1185,96 @@ router.get('/delivery-metrics', async (req, res) => {
}
});
// PO Pipeline — expected arrivals timeline + overdue summary
router.get('/pipeline', async (req, res) => {
try {
const pool = req.app.locals.pool;
// Stale PO filter (reused across queries)
const staleFilter = `
WITH stale AS (
SELECT po_id, pid
FROM purchase_orders po
WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent',
'electronically_ready_send', 'receiving_started')
AND po.expected_date IS NOT NULL
AND po.expected_date < CURRENT_DATE - INTERVAL '90 days'
AND EXISTS (
SELECT 1 FROM purchase_orders newer
WHERE newer.pid = po.pid
AND newer.status NOT IN ('canceled', 'done')
AND COALESCE(newer.date_ordered, newer.date_created)
> COALESCE(po.date_ordered, po.date_created)
)
)`;
// Expected arrivals by week (excludes stale POs)
const { rows: arrivals } = await pool.query(`
${staleFilter}
SELECT
DATE_TRUNC('week', po.expected_date)::date AS week,
COUNT(DISTINCT po.po_id) AS po_count,
ROUND(SUM(po.po_cost_price * po.ordered)::numeric, 0) AS expected_value,
COUNT(DISTINCT po.vendor) AS vendor_count
FROM purchase_orders po
WHERE po.status IN ('ordered', 'electronically_sent')
AND po.expected_date IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
GROUP BY 1
ORDER BY 1
`);
// Overdue POs (excludes stale)
const { rows: [overdue] } = await pool.query(`
${staleFilter}
SELECT
COUNT(DISTINCT po.po_id) AS po_count,
ROUND(COALESCE(SUM(po.po_cost_price * po.ordered), 0)::numeric, 0) AS total_value
FROM purchase_orders po
WHERE po.status IN ('ordered', 'electronically_sent')
AND po.expected_date IS NOT NULL
AND po.expected_date < CURRENT_DATE
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
`);
// Summary: on-order value from product_metrics (FIFO-accurate), PO counts from purchase_orders with staleness filter
const { rows: [summary] } = await pool.query(`
${staleFilter}
SELECT
COUNT(DISTINCT po.po_id) AS total_open_pos,
COUNT(DISTINCT po.vendor) AS vendor_count
FROM purchase_orders po
WHERE po.status IN ('ordered', 'electronically_sent')
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
`);
const { rows: [onOrderTotal] } = await pool.query(`
SELECT ROUND(COALESCE(SUM(on_order_cost), 0)::numeric, 0) AS total_on_order_value
FROM product_metrics
WHERE is_visible = true
`);
res.json({
arrivals: arrivals.map(r => ({
week: r.week,
poCount: Number(r.po_count) || 0,
expectedValue: Number(r.expected_value) || 0,
vendorCount: Number(r.vendor_count) || 0,
})),
overdue: {
count: Number(overdue.po_count) || 0,
value: Number(overdue.total_value) || 0,
},
summary: {
totalOpenPOs: Number(summary.total_open_pos) || 0,
totalOnOrderValue: Number(onOrderTotal.total_on_order_value) || 0,
vendorCount: Number(summary.vendor_count) || 0,
},
});
} catch (error) {
console.error('Error fetching PO pipeline:', error);
res.status(500).json({ error: 'Failed to fetch PO pipeline' });
}
});
module.exports = router;

View File

@@ -24,6 +24,7 @@ const vendorsAggregateRouter = require('./routes/vendorsAggregate');
const brandsAggregateRouter = require('./routes/brandsAggregate');
const htsLookupRouter = require('./routes/hts-lookup');
const importSessionsRouter = require('./routes/import-sessions');
const newsletterRouter = require('./routes/newsletter');
// Get the absolute path to the .env file
const envPath = '/var/www/html/inventory/.env';
@@ -132,6 +133,7 @@ async function startServer() {
app.use('/api/reusable-images', reusableImagesRouter);
app.use('/api/hts-lookup', htsLookupRouter);
app.use('/api/import-sessions', importSessionsRouter);
app.use('/api/newsletter', newsletterRouter);
// Basic health check route
app.get('/health', (req, res) => {

View File

@@ -4313,9 +4313,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001739",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz",
"integrity": "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==",
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
"integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
"dev": true,
"funding": [
{

View File

@@ -28,6 +28,7 @@ const Categories = lazy(() => import('./pages/Categories'));
const Brands = lazy(() => import('./pages/Brands'));
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
const Newsletter = lazy(() => import('./pages/Newsletter'));
// 2. Dashboard app - separate chunk
const Dashboard = lazy(() => import('./pages/Dashboard'));
@@ -215,6 +216,15 @@ function App() {
</Protected>
} />
{/* Newsletter recommendations */}
<Route path="/newsletter" element={
<Protected page="newsletter">
<Suspense fallback={<PageLoading />}>
<Newsletter />
</Suspense>
</Protected>
} />
{/* Dashboard app - separate chunk */}
<Route path="/dashboard" element={
<Protected page="dashboard">

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

@@ -0,0 +1,143 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Cell,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface AgingCohort {
cohort: string;
productCount: number;
avgSellThrough: number;
stockCost: number;
revenue: number;
unitsSold: number;
}
function getSellThroughColor(rate: number): string {
if (rate >= 30) return METRIC_COLORS.revenue;
if (rate >= 15) return METRIC_COLORS.orders;
if (rate >= 5) return METRIC_COLORS.comparison;
return '#ef4444';
}
export function AgingSellThrough() {
const { data, isLoading, isError } = useQuery<AgingCohort[]>({
queryKey: ['aging-sell-through'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/aging`);
if (!response.ok) throw new Error('Failed to fetch aging data');
return response.json();
},
});
if (isError) {
return (
<Card>
<CardHeader><CardTitle>Aging & Sell-Through</CardTitle></CardHeader>
<CardContent>
<div className="h-[350px] flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load aging data</p>
</div>
</CardContent>
</Card>
);
}
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Aging & Sell-Through</CardTitle></CardHeader>
<CardContent>
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading aging data...</div>
</div>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Sell-Through Rate by Age</CardTitle>
<p className="text-xs text-muted-foreground">
Avg 30-day sell-through % for products by age since first received
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="cohort" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as AgingCohort;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">Age: {d.cohort}</p>
<p>Sell-through: <span className="font-medium">{d.avgSellThrough}%</span></p>
<p>{d.productCount.toLocaleString()} products</p>
<p>Stock value: {formatCurrency(d.stockCost)}</p>
</div>
);
}}
/>
<Bar dataKey="avgSellThrough" name="Sell-Through %" radius={[4, 4, 0, 0]}>
{data.map((entry, i) => (
<Cell key={i} fill={getSellThroughColor(entry.avgSellThrough)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Capital Tied Up by Age</CardTitle>
<p className="text-xs text-muted-foreground">
Stock investment distribution across product age cohorts
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="cohort" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={formatCurrency} tick={{ fontSize: 11 }} />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as AgingCohort;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">Age: {d.cohort}</p>
<p>Stock cost: {formatCurrency(d.stockCost)}</p>
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
<p>{d.productCount.toLocaleString()} products</p>
<p>{d.unitsSold.toLocaleString()} units sold (30d)</p>
</div>
);
}}
/>
<Bar dataKey="stockCost" name="Stock Investment" fill={METRIC_COLORS.aov} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,201 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ScatterChart,
Scatter,
ZAxis,
Cell,
ReferenceLine,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface BrandData {
brand: string;
productCount: number;
stockCost: number;
profit30d: number;
revenue30d: number;
gmroi: number;
}
interface EfficiencyData {
brands: BrandData[];
}
function getGmroiColor(gmroi: number): string {
if (gmroi >= 3) return METRIC_COLORS.revenue; // emerald — strong
if (gmroi >= 1) return METRIC_COLORS.comparison; // amber — acceptable
return '#ef4444'; // red — poor
}
type GmroiView = 'top' | 'bottom';
export function CapitalEfficiency() {
const [gmroiView, setGmroiView] = useState<GmroiView>('top');
const { data, isLoading, isError } = useQuery<EfficiencyData>({
queryKey: ['capital-efficiency'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/efficiency`);
if (!response.ok) throw new Error('Failed to fetch capital efficiency');
return response.json();
},
});
if (isError) {
return (
<Card>
<CardHeader><CardTitle>Capital Efficiency</CardTitle></CardHeader>
<CardContent>
<div className="h-[350px] flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load efficiency data</p>
</div>
</CardContent>
</Card>
);
}
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Capital Efficiency</CardTitle></CardHeader>
<CardContent>
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading efficiency...</div>
</div>
</CardContent>
</Card>
);
}
// Top or bottom 15 by GMROI for bar chart
const sortedGmroi = gmroiView === 'top'
? [...data.brands].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15)
: [...data.brands].sort((a, b) => a.gmroi - b.gmroi).slice(0, 15);
return (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>GMROI by Brand</CardTitle>
<p className="text-xs text-muted-foreground">
Annualized gross margin return on investment (top 30 brands by stock value)
</p>
</div>
<div className="flex gap-1">
{(['top', 'bottom'] as GmroiView[]).map((v) => (
<button
key={v}
onClick={() => setGmroiView(v)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
gmroiView === v
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{v === 'top' ? 'Best' : 'Worst'}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={sortedGmroi} layout="vertical">
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis
type="category"
dataKey="brand"
width={140}
tick={{ fontSize: 11 }}
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as BrandData;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.brand}</p>
<p>GMROI: <span className="font-medium">{d.gmroi.toFixed(2)}</span></p>
<p>Stock Investment: {formatCurrency(d.stockCost)}</p>
<p>Profit (30d): {formatCurrency(d.profit30d)}</p>
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
<p>{d.productCount} products</p>
</div>
);
}}
/>
<ReferenceLine x={3} stroke="#9ca3af" strokeDasharray="3 3" label={{ value: '3.0', position: 'top', fontSize: 10 }} />
<Bar dataKey="gmroi" name="GMROI" radius={[0, 4, 4, 0]}>
{sortedGmroi.map((entry, i) => (
<Cell key={i} fill={getGmroiColor(entry.gmroi)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Investment vs Profit by Brand</CardTitle>
<p className="text-xs text-muted-foreground">
Bubble size = product count. Ideal: high profit, low stock cost.
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="stockCost"
name="Stock Investment"
tickFormatter={formatCurrency}
tick={{ fontSize: 11 }}
type="number"
label={{ value: 'Stock Investment', position: 'insideBottom', offset: -5, fontSize: 12, fill: '#888' }}
/>
<YAxis
dataKey="profit30d"
name="Profit (30d)"
tickFormatter={formatCurrency}
tick={{ fontSize: 11 }}
type="number"
label={{ value: 'Profit (30d)', angle: -90, position: 'insideLeft', offset: 10, fontSize: 12, fill: '#888' }}
/>
<ZAxis dataKey="productCount" range={[40, 400]} name="Products" />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as BrandData;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.brand}</p>
<p>Stock Investment: {formatCurrency(d.stockCost)}</p>
<p>Profit (30d): {formatCurrency(d.profit30d)}</p>
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
<p>{d.productCount} products</p>
</div>
);
}}
/>
<Scatter data={data.brands} fill={METRIC_COLORS.orders} fillOpacity={0.6} />
</ScatterChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,202 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, PieChart, Pie, Cell, Legend } from 'recharts';
import config from '../../config';
interface CategoryData {
performance: {
category: string;
categoryPath: string; // Full hierarchy path
revenue: number;
profit: number;
growth: number;
productCount: number;
}[];
distribution: {
category: string;
categoryPath: string; // Full hierarchy path
value: number;
}[];
trends: {
category: string;
categoryPath: string; // Full hierarchy path
month: string;
sales: number;
}[];
}
const COLORS = ['#4ade80', '#60a5fa', '#f87171', '#fbbf24', '#a78bfa', '#f472b6'];
export function CategoryPerformance() {
const { data, isLoading } = useQuery<CategoryData>({
queryKey: ['category-performance'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/categories`);
if (!response.ok) {
throw new Error('Failed to fetch category performance');
}
const rawData = await response.json();
return {
performance: rawData.performance.map((item: any) => ({
category: item.category || '',
categoryPath: item.categoryPath || item.categorypath || item.category || '',
revenue: Number(item.revenue) || 0,
profit: Number(item.profit) || 0,
growth: Number(item.growth) || 0,
productCount: Number(item.productCount) || Number(item.productcount) || 0
})),
distribution: rawData.distribution.map((item: any) => ({
category: item.category || '',
categoryPath: item.categoryPath || item.categorypath || item.category || '',
value: Number(item.value) || 0
})),
trends: rawData.trends.map((item: any) => ({
category: item.category || '',
categoryPath: item.categoryPath || item.categorypath || item.category || '',
month: item.month || '',
sales: Number(item.sales) || 0
}))
};
},
});
if (isLoading || !data) {
return <div>Loading category performance...</div>;
}
const formatGrowth = (growth: number) => {
const value = growth >= 0 ? `+${growth.toFixed(1)}%` : `${growth.toFixed(1)}%`;
const color = growth >= 0 ? 'text-green-500' : 'text-red-500';
return <span className={color}>{value}</span>;
};
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Category Revenue Distribution</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.distribution}
dataKey="value"
nameKey="categoryPath"
cx="50%"
cy="50%"
outerRadius={100}
fill="#8884d8"
label={({ categoryPath }) => getShortCategoryName(categoryPath)}
>
{data.distribution.map((entry, index) => (
<Cell
key={`${entry.category}-${entry.value}-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number, _: string, props: any) => [
`$${value.toLocaleString()}`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
<div className="mt-1">Revenue</div>
</div>
]}
/>
<Legend
formatter={(value) => getShortCategoryName(value)}
wrapperStyle={{ fontSize: '12px' }}
/>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Category Growth Rates</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.performance}>
<XAxis
dataKey="categoryPath"
tick={({ x, y, payload }) => (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="end"
fill="#888888"
transform="rotate(-35)"
>
{getShortCategoryName(payload.value)}
</text>
</g>
)}
/>
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
formatter={(value: number, _: string, props: any) => [
`${value.toFixed(1)}%`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
<div className="mt-1">Growth Rate</div>
</div>
]}
/>
<Bar
dataKey="growth"
fill="#4ade80"
name="Growth Rate"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Category Performance Details</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.performance.map((category) => (
<div key={`${category.category}-${category.revenue}`} className="flex items-center">
<div className="flex-1">
<div className="space-y-1">
<p className="text-sm font-medium">{getShortCategoryName(category.categoryPath)}</p>
<p className="text-xs text-muted-foreground">{category.categoryPath}</p>
</div>
<p className="text-sm text-muted-foreground mt-1">
{category.productCount} products
</p>
</div>
<div className="ml-4 text-right space-y-1">
<p className="text-sm font-medium">
${category.revenue.toLocaleString()} revenue
</p>
<p className="text-sm text-muted-foreground">
${category.profit.toLocaleString()} profit
</p>
<p className="text-sm text-muted-foreground">
Growth: {formatGrowth(category.growth)}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Legend,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface DiscountRow {
abcClass: string;
discountBucket: string;
productCount: number;
avgSellThrough: number;
revenue: number;
discountAmount: number;
profit: number;
}
const CLASS_COLORS: Record<string, string> = {
A: METRIC_COLORS.revenue,
B: METRIC_COLORS.orders,
C: METRIC_COLORS.comparison,
};
export function DiscountImpact() {
const { data, isLoading, isError } = useQuery<DiscountRow[]>({
queryKey: ['discount-impact'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/discounts`);
if (!response.ok) throw new Error('Failed to fetch discount data');
return response.json();
},
});
if (isError) {
return (
<Card>
<CardHeader><CardTitle>Discount Impact</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load discount data</p>
</div>
</CardContent>
</Card>
);
}
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Discount Impact</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading discount data...</div>
</div>
</CardContent>
</Card>
);
}
// Pivot: for each discount bucket, show avg sell-through by ABC class
const buckets = ['No Discount', '1-10%', '11-20%', '21-30%', '30%+'];
const chartData = buckets.map(bucket => {
const row: Record<string, string | number> = { bucket };
['A', 'B', 'C'].forEach(cls => {
const match = data.find(d => d.discountBucket === bucket && d.abcClass === cls);
row[`Class ${cls}`] = match?.avgSellThrough || 0;
});
return row;
});
// Summary by ABC class
const classSummary = ['A', 'B', 'C'].map(cls => {
const rows = data.filter(d => d.abcClass === cls);
return {
abcClass: cls,
totalProducts: rows.reduce((s, r) => s + r.productCount, 0),
totalDiscounts: rows.reduce((s, r) => s + r.discountAmount, 0),
totalRevenue: rows.reduce((s, r) => s + r.revenue, 0),
totalProfit: rows.reduce((s, r) => s + r.profit, 0),
};
});
return (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Sell-Through by Discount Level</CardTitle>
<p className="text-xs text-muted-foreground">
Avg 30-day sell-through % at each discount bracket, by ABC class
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="bucket" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(value: number) => [`${value}%`]} />
<Legend />
<Bar dataKey="Class A" fill={CLASS_COLORS.A} radius={[2, 2, 0, 0]} />
<Bar dataKey="Class B" fill={CLASS_COLORS.B} radius={[2, 2, 0, 0]} />
<Bar dataKey="Class C" fill={CLASS_COLORS.C} radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Discount Leakage by Class</CardTitle>
<p className="text-xs text-muted-foreground">
How much discount is given relative to revenue per ABC class
</p>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-2 text-left font-medium">Class</th>
<th className="px-4 py-2 text-right font-medium">Products</th>
<th className="px-4 py-2 text-right font-medium">Revenue</th>
<th className="px-4 py-2 text-right font-medium">Discounts</th>
<th className="px-4 py-2 text-right font-medium">Disc %</th>
<th className="px-4 py-2 text-right font-medium">Profit</th>
</tr>
</thead>
<tbody>
{classSummary.map((row) => (
<tr key={row.abcClass} className="border-b">
<td className="px-4 py-2 font-medium">Class {row.abcClass}</td>
<td className="px-4 py-2 text-right">{row.totalProducts.toLocaleString()}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.totalRevenue)}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.totalDiscounts)}</td>
<td className="px-4 py-2 text-right">
{row.totalRevenue > 0
? ((row.totalDiscounts / (row.totalRevenue + row.totalDiscounts)) * 100).toFixed(1)
: '0'}%
</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.totalProfit)}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,217 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Legend,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { TrendingUp, TrendingDown, Plus, Archive } from 'lucide-react';
import { formatCurrency } from '@/utils/formatCurrency';
interface GrowthRow {
abcClass: string;
growthBucket: string;
productCount: number;
revenue: number;
stockCost: number;
}
interface GrowthSummary {
comparableCount: number;
growingCount: number;
decliningCount: number;
weightedAvgGrowth: number;
medianGrowth: number;
}
interface CatalogTurnover {
newProducts: number;
newProductRevenue: number;
discontinued: number;
discontinuedStockValue: number;
}
interface GrowthData {
byClass: GrowthRow[];
summary: GrowthSummary;
turnover: CatalogTurnover;
}
const GROWTH_COLORS: Record<string, string> = {
'Strong Growth (>50%)': METRIC_COLORS.revenue,
'Growing (0-50%)': '#34d399',
'Declining (0-50%)': METRIC_COLORS.comparison,
'Sharp Decline (>50%)': '#ef4444',
};
export function GrowthMomentum() {
const { data, isLoading, isError } = useQuery<GrowthData>({
queryKey: ['growth-momentum'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/growth`);
if (!response.ok) throw new Error('Failed to fetch growth data');
return response.json();
},
});
if (isError) {
return (
<Card>
<CardHeader><CardTitle>YoY Growth Momentum</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load growth data</p>
</div>
</CardContent>
</Card>
);
}
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>YoY Growth Momentum</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading growth data...</div>
</div>
</CardContent>
</Card>
);
}
const { summary, turnover } = data;
const growingPct = summary.comparableCount > 0
? ((summary.growingCount / summary.comparableCount) * 100).toFixed(0)
: '0';
// Pivot: for each ABC class, show product counts by growth bucket
const classes = ['A', 'B', 'C'];
const buckets = ['Strong Growth (>50%)', 'Growing (0-50%)', 'Declining (0-50%)', 'Sharp Decline (>50%)'];
const chartData = classes.map(cls => {
const row: Record<string, string | number> = { abcClass: `Class ${cls}` };
buckets.forEach(bucket => {
const match = data.byClass.find(d => d.abcClass === cls && d.growthBucket === bucket);
row[bucket] = match?.productCount || 0;
});
return row;
});
return (
<div className="space-y-4">
{/* Row 1: Comparable growth metrics */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-green-500/10">
<TrendingUp className="h-4 w-4 text-green-500" />
</div>
<div>
<p className="text-sm font-medium">Comparable Growing</p>
<p className="text-xl font-bold">{growingPct}%</p>
<p className="text-xs text-muted-foreground">{summary.growingCount.toLocaleString()} of {summary.comparableCount.toLocaleString()} products</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-red-500/10">
<TrendingDown className="h-4 w-4 text-red-500" />
</div>
<div>
<p className="text-sm font-medium">Comparable Declining</p>
<p className="text-xl font-bold">{summary.decliningCount.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">products with lower YoY sales</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">Weighted Avg Growth</p>
<p className={`text-2xl font-bold ${summary.weightedAvgGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{summary.weightedAvgGrowth > 0 ? '+' : ''}{summary.weightedAvgGrowth}%
</p>
<p className="text-xs text-muted-foreground">revenue-weighted</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">Median YoY Growth</p>
<p className={`text-2xl font-bold ${summary.medianGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{summary.medianGrowth > 0 ? '+' : ''}{summary.medianGrowth}%
</p>
<p className="text-xs text-muted-foreground">typical product growth</p>
</CardContent>
</Card>
</div>
{/* Row 2: Catalog turnover */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-blue-500/10">
<Plus className="h-4 w-4 text-blue-500" />
</div>
<div>
<p className="text-sm font-medium">New Products (&lt;1yr)</p>
<p className="text-xl font-bold">{turnover.newProducts.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{formatCurrency(turnover.newProductRevenue)} revenue (30d)</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-amber-500/10">
<Archive className="h-4 w-4 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium">Discontinued</p>
<p className="text-xl font-bold">{turnover.discontinued.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">
{turnover.discontinuedStockValue > 0
? `${formatCurrency(turnover.discontinuedStockValue)} still in stock`
: 'no remaining stock'}
</p>
</div>
</CardContent>
</Card>
</div>
{/* Chart: comparable products only */}
<Card>
<CardHeader>
<CardTitle>Comparable Growth by ABC Class</CardTitle>
<p className="text-xs text-muted-foreground">
Products selling in both this and last year's period — excludes new launches and discontinued
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="abcClass" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 11 }} label={{ value: 'Products', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
{buckets.map(bucket => (
<Bar
key={bucket}
dataKey={bucket}
stackId="growth"
fill={GROWTH_COLORS[bucket]}
/>
))}
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,186 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Line,
ComposedChart,
Legend,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
import { ArrowDownToLine, ArrowUpFromLine, TrendingUp } from 'lucide-react';
interface FlowPoint {
date: string;
unitsReceived: number;
costReceived: number;
unitsSold: number;
cogsSold: number;
}
type Period = 30 | 90;
function formatDate(dateStr: string, period: Period): string {
const d = new Date(dateStr);
if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
export function InventoryFlow() {
const [period, setPeriod] = useState<Period>(30);
const { data, isLoading, isError } = useQuery<FlowPoint[]>({
queryKey: ['inventory-flow', period],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/flow?period=${period}`);
if (!response.ok) throw new Error('Failed to fetch inventory flow');
return response.json();
},
});
const totals = useMemo(() => {
if (!data) return { received: 0, sold: 0, net: 0 };
const received = data.reduce((s, d) => s + d.costReceived, 0);
const sold = data.reduce((s, d) => s + d.cogsSold, 0);
return { received, sold, net: received - sold };
}, [data]);
const chartData = useMemo(() => {
if (!data) return [];
return data.map(d => ({
...d,
netFlow: d.costReceived - d.cogsSold,
}));
}, [data]);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Inventory Flow: Receiving vs Selling</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Daily cost of goods received vs cost of goods sold
</p>
</div>
<div className="flex gap-1">
{([30, 90] as Period[]).map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
period === p
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{`${p}D`}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent>
{isError ? (
<div className="h-[400px] flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load flow data</p>
</div>
) : isLoading || !data ? (
<div className="h-[400px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading flow data...</div>
</div>
) : (
<div className="space-y-4">
{/* Summary stats */}
<div className="grid gap-4 md:grid-cols-3">
<div className="flex items-center gap-3 rounded-lg border p-3">
<div className="rounded-full p-2 bg-green-500/10">
<ArrowDownToLine className="h-4 w-4 text-green-500" />
</div>
<div>
<p className="text-xs text-muted-foreground">Total Received</p>
<p className="text-lg font-bold">{formatCurrency(totals.received)}</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-lg border p-3">
<div className="rounded-full p-2 bg-blue-500/10">
<ArrowUpFromLine className="h-4 w-4 text-blue-500" />
</div>
<div>
<p className="text-xs text-muted-foreground">Total Sold (COGS)</p>
<p className="text-lg font-bold">{formatCurrency(totals.sold)}</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-lg border p-3">
<div className={`rounded-full p-2 ${totals.net >= 0 ? 'bg-amber-500/10' : 'bg-green-500/10'}`}>
<TrendingUp className={`h-4 w-4 ${totals.net >= 0 ? 'text-amber-500' : 'text-green-500'}`} />
</div>
<div>
<p className="text-xs text-muted-foreground">Net Change</p>
<p className={`text-lg font-bold ${totals.net >= 0 ? 'text-amber-600' : 'text-green-600'}`}>
{totals.net >= 0 ? '+' : ''}{formatCurrency(totals.net)}
</p>
<p className="text-xs text-muted-foreground">
{totals.net >= 0 ? 'inventory growing' : 'inventory shrinking'}
</p>
</div>
</div>
</div>
{/* Chart */}
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tickFormatter={(v) => formatDate(v, period)}
tick={{ fontSize: 12 }}
interval={period === 90 ? 6 : 2}
/>
<YAxis
tickFormatter={formatCurrency}
tick={{ fontSize: 12 }}
width={60}
/>
<Tooltip
labelFormatter={(v) => new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
formatter={(value: number, name: string) => [formatCurrency(value), name]}
/>
<Legend />
<Bar
dataKey="costReceived"
fill={METRIC_COLORS.revenue}
name="Received (Cost)"
radius={[2, 2, 0, 0]}
opacity={0.7}
/>
<Bar
dataKey="cogsSold"
fill={METRIC_COLORS.orders}
name="Sold (COGS)"
radius={[2, 2, 0, 0]}
opacity={0.7}
/>
<Line
type="monotone"
dataKey="netFlow"
stroke={METRIC_COLORS.comparison}
name="Net Flow"
strokeWidth={1.5}
dot={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,131 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Line,
ComposedChart,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
interface TrendPoint {
date: string;
stockoutCount: number;
unitsSold: number;
}
type Period = 30 | 90 | 365;
function formatDate(dateStr: string, period: Period): string {
const d = new Date(dateStr);
if (period === 365) return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
export function InventoryTrends() {
const [period, setPeriod] = useState<Period>(90);
const { data, isLoading, isError } = useQuery<TrendPoint[]>({
queryKey: ['inventory-trends', period],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/inventory-trends?period=${period}`);
if (!response.ok) throw new Error('Failed to fetch inventory trends');
return response.json();
},
});
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Daily Sales Activity & Stockouts</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Units sold per day with stockout product count overlay
</p>
</div>
<div className="flex gap-1">
{([30, 90, 365] as Period[]).map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
period === p
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{p === 365 ? '1Y' : `${p}D`}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent>
{isError ? (
<div className="h-[350px] flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load trends data</p>
</div>
) : isLoading || !data ? (
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading trends...</div>
</div>
) : (
<ResponsiveContainer width="100%" height={350}>
<ComposedChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tickFormatter={(v) => formatDate(v, period)}
tick={{ fontSize: 12 }}
interval={period === 365 ? 29 : period === 90 ? 6 : 2}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 12 }}
width={60}
label={{ value: 'Units Sold', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12 }}
width={60}
label={{ value: 'Stockouts', angle: 90, position: 'insideRight', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<Tooltip
labelFormatter={(v) => new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
<Bar
yAxisId="left"
dataKey="unitsSold"
fill={METRIC_COLORS.orders}
name="Units Sold"
radius={[2, 2, 0, 0]}
opacity={0.7}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="stockoutCount"
stroke="#ef4444"
name="Products Stocked Out"
strokeWidth={1.5}
dot={false}
strokeDasharray="4 2"
/>
</ComposedChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,159 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
Area,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Line,
ComposedChart,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface ValuePoint {
date: string;
totalValue: number;
productCount: number;
}
type Period = 30 | 90 | 365;
function formatDate(dateStr: string, period: Period): string {
const d = new Date(dateStr);
if (period === 365) return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
export function InventoryValueTrend() {
const [period, setPeriod] = useState<Period>(90);
const { data, isLoading, isError } = useQuery<ValuePoint[]>({
queryKey: ['inventory-value', period],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/inventory-value?period=${period}`);
if (!response.ok) throw new Error('Failed to fetch inventory value');
return response.json();
},
});
const latest = data?.[data.length - 1];
const earliest = data?.[0];
const change = latest && earliest ? latest.totalValue - earliest.totalValue : 0;
const changePct = earliest && earliest.totalValue > 0
? ((change / earliest.totalValue) * 100).toFixed(1)
: '0';
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Inventory Value Over Time</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Total stock investment (cost) with product count overlay
{latest && (
<span className="ml-2">
Current: <span className="font-medium">{formatCurrency(latest.totalValue)}</span>
{' '}
<span className={change >= 0 ? 'text-green-600' : 'text-red-600'}>
({change >= 0 ? '+' : ''}{changePct}%)
</span>
</span>
)}
</p>
</div>
<div className="flex gap-1">
{([30, 90, 365] as Period[]).map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
period === p
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{p === 365 ? '1Y' : `${p}D`}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent>
{isError ? (
<div className="h-[350px] flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load inventory value data</p>
</div>
) : isLoading || !data ? (
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading inventory value...</div>
</div>
) : (
<ResponsiveContainer width="100%" height={350}>
<ComposedChart data={data}>
<defs>
<linearGradient id="valueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={METRIC_COLORS.revenue} stopOpacity={0.3} />
<stop offset="95%" stopColor={METRIC_COLORS.revenue} stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tickFormatter={(v) => formatDate(v, period)}
tick={{ fontSize: 12 }}
interval={period === 365 ? 29 : period === 90 ? 6 : 2}
/>
<YAxis
yAxisId="left"
tickFormatter={formatCurrency}
tick={{ fontSize: 12 }}
width={70}
label={{ value: 'Stock Value', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12 }}
width={60}
label={{ value: 'Products', angle: 90, position: 'insideRight', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<Tooltip
labelFormatter={(v) => new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
formatter={(value: number, name: string) => [
name === 'Stock Value' ? formatCurrency(value) : value.toLocaleString(),
name,
]}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="totalValue"
fill="url(#valueGradient)"
stroke={METRIC_COLORS.revenue}
strokeWidth={2}
name="Stock Value"
/>
<Line
yAxisId="right"
type="monotone"
dataKey="productCount"
stroke={METRIC_COLORS.orders}
name="Products in Stock"
strokeWidth={1.5}
dot={false}
strokeDasharray="4 2"
/>
</ComposedChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,226 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
CartesianGrid,
} from 'recharts';
import config from '../../config';
import { PackageX, Archive } from 'lucide-react';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface AbcItem {
abcClass: string;
productCount: number;
revenue: number;
stockCost: number;
profit: number;
unitsSold: number;
}
interface StockIssues {
deadStockCount: number;
deadStockCost: number;
deadStockRetail: number;
overstockCount: number;
overstockCost: number;
overstockRetail: number;
}
interface PortfolioData {
abcBreakdown: AbcItem[];
stockIssues: StockIssues;
}
export function PortfolioAnalysis() {
const { data, isLoading, isError } = useQuery<PortfolioData>({
queryKey: ['portfolio-analysis'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/portfolio`);
if (!response.ok) throw new Error('Failed to fetch portfolio analysis');
return response.json();
},
});
if (isError) {
return (
<Card>
<CardHeader><CardTitle>Portfolio & ABC Analysis</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load portfolio data</p>
</div>
</CardContent>
</Card>
);
}
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Portfolio & ABC Analysis</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading portfolio...</div>
</div>
</CardContent>
</Card>
);
}
// Include all classes — rename N/A to "Unclassified"
const allClasses = data.abcBreakdown.map(r => ({
...r,
abcClass: r.abcClass === 'N/A' ? 'Unclassified' : r.abcClass,
}));
const totalRevenue = allClasses.reduce((s, r) => s + r.revenue, 0);
const totalStockCost = allClasses.reduce((s, r) => s + r.stockCost, 0);
const totalProducts = allClasses.reduce((s, r) => s + r.productCount, 0);
// Compute percentage data for the grouped bar chart
const chartData = allClasses.map(r => ({
abcClass: r.abcClass === 'Unclassified' ? 'Unclassified' : `Class ${r.abcClass}`,
'% of Products': totalProducts > 0 ? Number(((r.productCount / totalProducts) * 100).toFixed(1)) : 0,
'% of Revenue': totalRevenue > 0 ? Number(((r.revenue / totalRevenue) * 100).toFixed(1)) : 0,
'% of Stock Investment': totalStockCost > 0 ? Number(((r.stockCost / totalStockCost) * 100).toFixed(1)) : 0,
}));
const abcOnly = allClasses.filter(r => ['A', 'B', 'C'].includes(r.abcClass));
const abcRevenue = abcOnly.reduce((s, r) => s + r.revenue, 0);
const aClass = allClasses.find(r => r.abcClass === 'A');
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>ABC Class Distribution</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="abcClass" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 12 }} />
<Tooltip formatter={(value: number) => [`${value}%`]} />
<Legend />
<Bar dataKey="% of Products" fill={METRIC_COLORS.orders} radius={[2, 2, 0, 0]} />
<Bar dataKey="% of Revenue" fill={METRIC_COLORS.revenue} radius={[2, 2, 0, 0]} />
<Bar dataKey="% of Stock Investment" fill={METRIC_COLORS.aov} radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<div className="grid gap-4 grid-rows-3">
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="rounded-full p-2 bg-green-500/10">
<TrendingUpIcon className="h-5 w-5 text-green-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">A-Class Products</p>
<p className="text-xs text-muted-foreground">
{aClass ? aClass.productCount.toLocaleString() : 0} products
</p>
</div>
<div className="text-right">
<p className="text-sm font-bold">
{abcRevenue > 0 && aClass ? ((aClass.revenue / abcRevenue) * 100).toFixed(0) : 0}% of classified revenue
</p>
<p className="text-xs text-muted-foreground">
{formatCurrency(aClass?.revenue || 0)} (30d)
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="rounded-full p-2 bg-amber-500/10">
<Archive className="h-5 w-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Dead Stock</p>
<p className="text-xs text-muted-foreground">
{data.stockIssues.deadStockCount.toLocaleString()} products
</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-amber-500">
{formatCurrency(data.stockIssues.deadStockCost)}
</p>
<p className="text-xs text-muted-foreground">capital tied up</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="rounded-full p-2 bg-red-500/10">
<PackageX className="h-5 w-5 text-red-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Overstock</p>
<p className="text-xs text-muted-foreground">
{data.stockIssues.overstockCount.toLocaleString()} products
</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-red-500">
{formatCurrency(data.stockIssues.overstockCost)}
</p>
<p className="text-xs text-muted-foreground">excess investment</p>
</div>
</CardContent>
</Card>
</div>
</div>
{/* ABC breakdown table */}
<Card>
<CardContent className="pt-6">
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-2 text-left font-medium">Class</th>
<th className="px-4 py-2 text-right font-medium">Products</th>
<th className="px-4 py-2 text-right font-medium">Revenue (30d)</th>
<th className="px-4 py-2 text-right font-medium">Profit (30d)</th>
<th className="px-4 py-2 text-right font-medium">Stock Cost</th>
<th className="px-4 py-2 text-right font-medium">Units Sold (30d)</th>
</tr>
</thead>
<tbody>
{allClasses.map((row) => (
<tr key={row.abcClass} className="border-b">
<td className="px-4 py-2 font-medium">{row.abcClass === 'Unclassified' ? 'Unclassified' : `Class ${row.abcClass}`}</td>
<td className="px-4 py-2 text-right">{row.productCount.toLocaleString()}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.revenue)}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.profit)}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.stockCost)}</td>
<td className="px-4 py-2 text-right">{row.unitsSold.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}
function TrendingUpIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
<polyline points="16 7 22 7 22 13" />
</svg>
);
}

View File

@@ -1,232 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, ScatterChart, Scatter, XAxis, YAxis, Tooltip, ZAxis, LineChart, Line } from 'recharts';
import config from '../../config';
interface PriceData {
pricePoints: {
price: number;
salesVolume: number;
revenue: number;
category: string;
}[];
elasticity: {
date: string;
price: number;
demand: number;
}[];
recommendations: {
product: string;
currentPrice: number;
recommendedPrice: number;
potentialRevenue: number;
confidence: number;
}[];
}
export function PriceAnalysis() {
const { data, isLoading, error } = useQuery<PriceData>({
queryKey: ['price-analysis'],
queryFn: async () => {
try {
const response = await fetch(`${config.apiUrl}/analytics/pricing`);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const rawData = await response.json();
if (!rawData || !rawData.pricePoints) {
return {
pricePoints: [],
elasticity: [],
recommendations: []
};
}
return {
pricePoints: (rawData.pricePoints || []).map((item: any) => ({
price: Number(item.price) || 0,
salesVolume: Number(item.salesVolume || item.salesvolume) || 0,
revenue: Number(item.revenue) || 0,
category: item.category || ''
})),
elasticity: (rawData.elasticity || []).map((item: any) => ({
date: item.date || '',
price: Number(item.price) || 0,
demand: Number(item.demand) || 0
})),
recommendations: (rawData.recommendations || []).map((item: any) => ({
product: item.product || '',
currentPrice: Number(item.currentPrice || item.currentprice) || 0,
recommendedPrice: Number(item.recommendedPrice || item.recommendedprice) || 0,
potentialRevenue: Number(item.potentialRevenue || item.potentialrevenue) || 0,
confidence: Number(item.confidence) || 0
}))
};
} catch (err) {
console.error('Error fetching price data:', err);
throw err;
}
},
retry: 1
});
if (isLoading) {
return <div>Loading price analysis...</div>;
}
if (error || !data) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Price Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-500">
Unable to load price analysis. The price metrics may need to be set up in the database.
</p>
</CardContent>
</Card>
);
}
// Early return if no data to display
if (
data.pricePoints.length === 0 &&
data.elasticity.length === 0 &&
data.recommendations.length === 0
) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Price Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
No price data available. This may be because the price metrics haven't been calculated yet.
</p>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Price Point Analysis</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<ScatterChart>
<XAxis
dataKey="price"
name="Price"
tickFormatter={(value) => `$${value}`}
/>
<YAxis
dataKey="salesVolume"
name="Sales Volume"
/>
<ZAxis
dataKey="revenue"
range={[50, 400]}
name="Revenue"
/>
<Tooltip
formatter={(value: number, name: string) => {
if (name === 'Price') return [`$${value}`, name];
if (name === 'Sales Volume') return [value.toLocaleString(), name];
if (name === 'Revenue') return [`$${value.toLocaleString()}`, name];
return [value, name];
}}
/>
<Scatter
data={data.pricePoints}
fill="#a78bfa"
name="Products"
/>
</ScatterChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Price Elasticity</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data.elasticity}>
<XAxis
dataKey="date"
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<YAxis yAxisId="left" orientation="left" stroke="#a78bfa" />
<YAxis
yAxisId="right"
orientation="right"
stroke="#4ade80"
tickFormatter={(value) => `$${value}`}
/>
<Tooltip
labelFormatter={(label) => new Date(label).toLocaleDateString()}
formatter={(value: number, name: string) => {
if (name === 'Price') return [`$${value}`, name];
return [value.toLocaleString(), name];
}}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="demand"
stroke="#a78bfa"
name="Demand"
/>
<Line
yAxisId="right"
type="monotone"
dataKey="price"
stroke="#4ade80"
name="Price"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Price Optimization Recommendations</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.recommendations.map((item) => (
<div key={`${item.product}-${item.currentPrice}`} className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium">{item.product}</p>
<p className="text-sm text-muted-foreground">
Current Price: ${item.currentPrice.toFixed(2)}
</p>
</div>
<div className="ml-4 text-right space-y-1">
<p className="text-sm font-medium">
Recommended: ${item.recommendedPrice.toFixed(2)}
</p>
<p className="text-sm text-muted-foreground">
Potential Revenue: ${item.potentialRevenue.toLocaleString()}
</p>
<p className="text-sm text-muted-foreground">
Confidence: {item.confidence}%
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,180 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts';
import config from '../../config';
interface ProfitData {
byCategory: {
category: string;
categoryPath: string; // Full hierarchy path
profitMargin: number;
revenue: number;
cost: number;
}[];
overTime: {
date: string;
profitMargin: number;
revenue: number;
cost: number;
}[];
topProducts: {
product: string;
category: string;
categoryPath: string; // Full hierarchy path
profitMargin: number;
revenue: number;
cost: number;
}[];
}
export function ProfitAnalysis() {
const { data, isLoading } = useQuery<ProfitData>({
queryKey: ['profit-analysis'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/profit`);
if (!response.ok) {
throw new Error('Failed to fetch profit analysis');
}
const rawData = await response.json();
return {
byCategory: rawData.byCategory.map((item: any) => ({
category: item.category || '',
categoryPath: item.categorypath || item.category || '',
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0
})),
overTime: rawData.overTime.map((item: any) => ({
date: item.date || '',
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0
})),
topProducts: rawData.topProducts.map((item: any) => ({
product: item.product || '',
category: item.category || '',
categoryPath: item.categorypath || item.category || '',
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0
}))
};
},
});
if (isLoading || !data) {
return <div>Loading profit analysis...</div>;
}
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Profit Margins by Category</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.byCategory}>
<XAxis
dataKey="categoryPath"
tick={({ x, y, payload }) => (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="end"
fill="#888888"
transform="rotate(-35)"
>
{getShortCategoryName(payload.value)}
</text>
</g>
)}
/>
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
formatter={(value: number, _: string, props: any) => [
`${value.toFixed(1)}%`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
<div className="mt-1">Profit Margin</div>
</div>
]}
/>
<Bar
dataKey="profitMargin"
fill="#4ade80"
name="Profit Margin"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profit Margin Trend</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data.overTime}>
<XAxis
dataKey="date"
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
labelFormatter={(label) => new Date(label).toLocaleDateString()}
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
/>
<Line
type="monotone"
dataKey="profitMargin"
stroke="#4ade80"
name="Profit Margin"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Top Performing Products by Profit Margin</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.topProducts.map((product) => (
<div key={`${product.product}-${product.category}`} className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium">{product.product}</p>
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium">Category:</p>
<p>{product.categoryPath}</p>
</div>
<p className="text-sm text-muted-foreground mt-1">
Revenue: ${product.revenue.toLocaleString()}
</p>
</div>
<div className="ml-4 text-right">
<p className="text-sm font-medium">
{product.profitMargin.toFixed(1)}% margin
</p>
<p className="text-sm text-muted-foreground">
Cost: ${product.cost.toLocaleString()}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,240 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
PieChart,
Pie,
Cell,
Legend,
Tooltip,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
import { Sun, Snowflake } from 'lucide-react';
interface PatternRow {
pattern: string;
productCount: number;
stockCost: number;
revenue: number;
}
interface PeakSeasonRow {
month: string;
productCount: number;
stockCost: number;
}
interface SeasonalData {
patterns: PatternRow[];
peakSeasons: PeakSeasonRow[];
}
const PATTERN_COLORS: Record<string, string> = {
none: '#94a3b8', // slate — no seasonality
moderate: METRIC_COLORS.comparison, // amber
strong: METRIC_COLORS.revenue, // emerald
unknown: '#cbd5e1', // light slate
};
const PATTERN_LABELS: Record<string, string> = {
none: 'No Seasonality',
moderate: 'Moderate',
strong: 'Strong',
unknown: 'Unknown',
};
export function SeasonalPatterns() {
const { data, isLoading, isError } = useQuery<SeasonalData>({
queryKey: ['seasonal-patterns'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/seasonal`);
if (!response.ok) throw new Error('Failed to fetch seasonal data');
return response.json();
},
});
if (isError) {
return (
<Card>
<CardHeader><CardTitle>Seasonal Patterns</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load seasonal data</p>
</div>
</CardContent>
</Card>
);
}
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Seasonal Patterns</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading seasonal data...</div>
</div>
</CardContent>
</Card>
);
}
const seasonal = data.patterns.filter(p => p.pattern === 'moderate' || p.pattern === 'strong');
const seasonalCount = seasonal.reduce((s, p) => s + p.productCount, 0);
const seasonalStockCost = seasonal.reduce((s, p) => s + p.stockCost, 0);
const totalProducts = data.patterns.reduce((s, p) => s + p.productCount, 0);
const seasonalPct = totalProducts > 0 ? ((seasonalCount / totalProducts) * 100).toFixed(0) : '0';
const donutData = data.patterns.map(p => ({
name: PATTERN_LABELS[p.pattern] || p.pattern,
value: p.productCount,
color: PATTERN_COLORS[p.pattern] || '#94a3b8',
stockCost: p.stockCost,
revenue: p.revenue,
}));
return (
<div className="space-y-4">
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-amber-500/10">
<Sun className="h-4 w-4 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium">Seasonal Products</p>
<p className="text-xl font-bold">{seasonalCount.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{seasonalPct}% of in-stock products</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-emerald-500/10">
<Snowflake className="h-4 w-4 text-emerald-500" />
</div>
<div>
<p className="text-sm font-medium">Seasonal Stock Value</p>
<p className="text-xl font-bold">{formatCurrency(seasonalStockCost)}</p>
<p className="text-xs text-muted-foreground">capital in seasonal items</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-blue-500/10">
<Sun className="h-4 w-4 text-blue-500" />
</div>
<div>
<p className="text-sm font-medium">Peak Months Tracked</p>
<p className="text-xl font-bold">{data.peakSeasons.length}</p>
<p className="text-xs text-muted-foreground">months with seasonal peaks</p>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* Donut chart */}
<Card>
<CardHeader>
<CardTitle>Demand Seasonality Distribution</CardTitle>
<p className="text-xs text-muted-foreground">
Products by seasonal demand pattern (in-stock only)
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={donutData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
label={({ name, value }) => `${name} (${value.toLocaleString()})`}
>
{donutData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.name}</p>
<p>{d.value.toLocaleString()} products</p>
<p>Stock value: {formatCurrency(d.stockCost)}</p>
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
</div>
);
}}
/>
<Legend
formatter={(value) => <span className="text-xs">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Peak season bar chart */}
<Card>
<CardHeader>
<CardTitle>Peak Season Distribution</CardTitle>
<p className="text-xs text-muted-foreground">
Which months seasonal products peak (moderate + strong patterns)
</p>
</CardHeader>
<CardContent>
{data.peakSeasons.length === 0 ? (
<div className="h-[300px] flex items-center justify-center">
<p className="text-sm text-muted-foreground">No peak season data available</p>
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.peakSeasons}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as PeakSeasonRow;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.month}</p>
<p>{d.productCount.toLocaleString()} seasonal products peak</p>
<p>Stock value: {formatCurrency(d.stockCost)}</p>
</div>
);
}}
/>
<Bar
dataKey="productCount"
fill={METRIC_COLORS.comparison}
name="Products"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,227 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts';
import { Badge } from '@/components/ui/badge';
import config from '../../config';
interface StockData {
turnoverByCategory: {
category: string;
turnoverRate: number;
averageStock: number;
totalSales: number;
}[];
stockLevels: {
date: string;
inStock: number;
lowStock: number;
outOfStock: number;
}[];
criticalItems: {
product: string;
sku: string;
stockQuantity: number;
reorderPoint: number;
turnoverRate: number;
daysUntilStockout: number;
}[];
}
export function StockAnalysis() {
const { data, isLoading, error } = useQuery<StockData>({
queryKey: ['stock-analysis'],
queryFn: async () => {
try {
const response = await fetch(`${config.apiUrl}/analytics/stock`);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const rawData = await response.json();
if (!rawData || !rawData.turnoverByCategory) {
return {
turnoverByCategory: [],
stockLevels: [],
criticalItems: []
};
}
return {
turnoverByCategory: (rawData.turnoverByCategory || []).map((item: any) => ({
category: item.category || '',
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
averageStock: Number(item.averageStock || item.averagestock) || 0,
totalSales: Number(item.totalSales || item.totalsales) || 0
})),
stockLevels: (rawData.stockLevels || []).map((item: any) => ({
date: item.date || '',
inStock: Number(item.inStock || item.instock) || 0,
lowStock: Number(item.lowStock || item.lowstock) || 0,
outOfStock: Number(item.outOfStock || item.outofstock) || 0
})),
criticalItems: (rawData.criticalItems || []).map((item: any) => ({
product: item.product || '',
sku: item.sku || '',
stockQuantity: Number(item.stockQuantity || item.stockquantity) || 0,
reorderPoint: Number(item.reorderPoint || item.reorderpoint) || 0,
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
daysUntilStockout: Number(item.daysUntilStockout || item.daysuntilstockout) || 0
}))
};
} catch (err) {
console.error('Error fetching stock data:', err);
throw err;
}
},
retry: 1
});
if (isLoading) {
return <div>Loading stock analysis...</div>;
}
if (error || !data) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Stock Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-500">
Unable to load stock analysis. The stock metrics may need to be set up in the database.
</p>
</CardContent>
</Card>
);
}
// Early return if no data to display
if (
data.turnoverByCategory.length === 0 &&
data.stockLevels.length === 0 &&
data.criticalItems.length === 0
) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Stock Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
No stock data available. This may be because the stock metrics haven't been calculated yet.
</p>
</CardContent>
</Card>
);
}
const getStockStatus = (daysUntilStockout: number) => {
if (daysUntilStockout <= 7) {
return <Badge variant="destructive">Critical</Badge>;
}
if (daysUntilStockout <= 14) {
return <Badge variant="outline">Warning</Badge>;
}
return <Badge variant="secondary">OK</Badge>;
};
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Stock Turnover by Category</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.turnoverByCategory}>
<XAxis dataKey="category" />
<YAxis tickFormatter={(value) => `${value.toFixed(1)}x`} />
<Tooltip
formatter={(value: number) => [`${value.toFixed(1)}x`, 'Turnover Rate']}
/>
<Bar
dataKey="turnoverRate"
fill="#fbbf24"
name="Turnover Rate"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Stock Level Trends</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data.stockLevels}>
<XAxis
dataKey="date"
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<YAxis />
<Tooltip
labelFormatter={(label) => new Date(label).toLocaleDateString()}
/>
<Line
type="monotone"
dataKey="inStock"
stroke="#4ade80"
name="In Stock"
/>
<Line
type="monotone"
dataKey="lowStock"
stroke="#fbbf24"
name="Low Stock"
/>
<Line
type="monotone"
dataKey="outOfStock"
stroke="#f87171"
name="Out of Stock"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Critical Stock Items</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.criticalItems.map((item) => (
<div key={`${item.sku}-${item.product}`} className="flex items-center">
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{item.product}</p>
{getStockStatus(item.daysUntilStockout)}
</div>
<p className="text-sm text-muted-foreground">
SKU: {item.sku}
</p>
</div>
<div className="ml-4 text-right space-y-1">
<p className="text-sm font-medium">
{item.stockQuantity} in stock
</p>
<p className="text-sm text-muted-foreground">
Reorder at: {item.reorderPoint}
</p>
<p className="text-sm text-muted-foreground">
{item.daysUntilStockout} days until stockout
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,257 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
PieChart,
Pie,
Cell,
Legend,
} from 'recharts';
import config from '../../config';
import { AlertTriangle, ShieldCheck, DollarSign } from 'lucide-react';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface CoverBucket {
bucket: string;
productCount: number;
stockCost: number;
}
interface DemandPattern {
pattern: string;
productCount: number;
revenue: number;
stockCost: number;
}
interface ServiceStats {
avgFillRate: number;
avgServiceLevel: number;
totalStockoutIncidents: number;
totalLostSalesIncidents: number;
totalLostUnits: number;
totalLostRevenue: number;
productsWithStockouts: number;
avgStockoutRate: number;
}
interface StockHealthData {
coverDistribution: CoverBucket[];
demandPatterns: DemandPattern[];
serviceStats: ServiceStats;
}
// Color palette for demand pattern donut chart
const DEMAND_COLORS = [
METRIC_COLORS.revenue, // emerald
METRIC_COLORS.orders, // blue
METRIC_COLORS.comparison, // amber
METRIC_COLORS.aov, // violet
METRIC_COLORS.secondary, // cyan
];
function getCoverColor(bucket: string): string {
if (bucket.includes('Stockout')) return '#ef4444'; // red
if (bucket.includes('1-7')) return METRIC_COLORS.expense; // orange — critical low
if (bucket.includes('8-14')) return METRIC_COLORS.comparison; // amber — low
if (bucket.includes('15-30')) return '#eab308'; // yellow — watch
if (bucket.includes('31-60')) return METRIC_COLORS.revenue; // emerald — healthy
if (bucket.includes('61-90')) return METRIC_COLORS.orders; // blue — comfortable
if (bucket.includes('91-180')) return METRIC_COLORS.aov; // violet — high
return METRIC_COLORS.secondary; // cyan — excess
}
export function StockHealth() {
const { data, isLoading, isError } = useQuery<StockHealthData>({
queryKey: ['stock-health'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/stock-health`);
if (!response.ok) throw new Error('Failed to fetch stock health');
return response.json();
},
});
if (isError) {
return (
<Card>
<CardHeader><CardTitle>Demand & Stock Health</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load stock health data</p>
</div>
</CardContent>
</Card>
);
}
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Demand & Stock Health</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading stock health...</div>
</div>
</CardContent>
</Card>
);
}
const { serviceStats } = data;
return (
<div className="space-y-4">
{/* Service Level Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-green-500/10">
<ShieldCheck className="h-4 w-4 text-green-500" />
</div>
<div>
<p className="text-sm font-medium">Fill Rate</p>
<p className="text-xl font-bold">{serviceStats.avgFillRate}%</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-blue-500/10">
<ShieldCheck className="h-4 w-4 text-blue-500" />
</div>
<div>
<p className="text-sm font-medium">Service Level</p>
<p className="text-xl font-bold">{serviceStats.avgServiceLevel}%</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-red-500/10">
<AlertTriangle className="h-4 w-4 text-red-500" />
</div>
<div>
<p className="text-sm font-medium">Stockout Incidents</p>
<p className="text-xl font-bold">{serviceStats.totalStockoutIncidents.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{serviceStats.productsWithStockouts} products affected</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-amber-500/10">
<DollarSign className="h-4 w-4 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium">Est. Lost Revenue</p>
<p className="text-xl font-bold">{formatCurrency(serviceStats.totalLostRevenue)}</p>
<p className="text-xs text-muted-foreground">{Math.round(serviceStats.totalLostUnits).toLocaleString()} units</p>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* Stock Cover Distribution */}
<Card>
<CardHeader>
<CardTitle>Stock Cover Distribution</CardTitle>
<p className="text-xs text-muted-foreground">
Days of stock cover across active replenishable products
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.coverDistribution}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="bucket"
tick={{ fontSize: 10 }}
angle={-30}
textAnchor="end"
height={60}
/>
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as CoverBucket;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.bucket}</p>
<p>{d.productCount.toLocaleString()} products</p>
<p>Stock value: {formatCurrency(d.stockCost)}</p>
</div>
);
}}
/>
<Bar dataKey="productCount" name="Products" radius={[4, 4, 0, 0]}>
{data.coverDistribution.map((entry, i) => (
<Cell key={i} fill={getCoverColor(entry.bucket)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Demand Pattern Distribution */}
<Card>
<CardHeader>
<CardTitle>Demand Patterns</CardTitle>
<p className="text-xs text-muted-foreground">
Distribution of demand variability across selling products
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.demandPatterns}
dataKey="productCount"
nameKey="pattern"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
label={({ pattern, productCount }) =>
`${pattern} (${productCount.toLocaleString()})`
}
>
{data.demandPatterns.map((_, i) => (
<Cell key={i} fill={DEMAND_COLORS[i % DEMAND_COLORS.length]} />
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as DemandPattern;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1 capitalize">{d.pattern}</p>
<p>{d.productCount.toLocaleString()} products</p>
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
<p>Stock value: {formatCurrency(d.stockCost)}</p>
</div>
);
}}
/>
<Legend
formatter={(value) => <span className="capitalize text-xs">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
ScatterChart,
Scatter,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ZAxis,
Cell,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface RiskProduct {
title: string;
sku: string;
brand: string;
leadTimeDays: number;
sellsOutInDays: number;
currentStock: number;
velocityDaily: number;
revenue30d: number;
abcClass: string;
}
interface RiskSummary {
atRiskCount: number;
criticalACount: number;
atRiskRevenue: number;
}
interface StockoutRiskData {
summary: RiskSummary;
products: RiskProduct[];
}
function getRiskColor(product: RiskProduct): string {
const buffer = product.sellsOutInDays - product.leadTimeDays;
if (buffer <= 0) return '#ef4444'; // Already past lead time — critical
if (buffer <= 7) return METRIC_COLORS.comparison; // Within a week — warning
return METRIC_COLORS.revenue; // Healthy buffer
}
export function StockoutRisk() {
const { data, isLoading, isError } = useQuery<StockoutRiskData>({
queryKey: ['stockout-risk'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/stockout-risk`);
if (!response.ok) throw new Error('Failed to fetch stockout risk');
return response.json();
},
});
if (isError) {
return (
<Card>
<CardHeader><CardTitle>Reorder Risk</CardTitle></CardHeader>
<CardContent>
<div className="h-[350px] flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load risk data</p>
</div>
</CardContent>
</Card>
);
}
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Reorder Risk</CardTitle></CardHeader>
<CardContent>
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading risk data...</div>
</div>
</CardContent>
</Card>
);
}
const { summary, products } = data;
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">At Risk Products</p>
<p className="text-2xl font-bold text-red-500">{summary.atRiskCount}</p>
<p className="text-xs text-muted-foreground">sells out before lead time</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">Critical A-Class</p>
<p className="text-2xl font-bold text-red-500">{summary.criticalACount}</p>
<p className="text-xs text-muted-foreground">top sellers at risk</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">At-Risk Revenue</p>
<p className="text-2xl font-bold text-amber-500">
{formatCurrency(summary.atRiskRevenue)}
</p>
<p className="text-xs text-muted-foreground">monthly revenue exposed</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Lead Time vs Sell-Out Timeline</CardTitle>
<p className="text-xs text-muted-foreground">
Products below the diagonal line will stock out before replenishment arrives (incl. on-order stock)
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="leadTimeDays"
name="Lead Time"
tick={{ fontSize: 11 }}
type="number"
label={{ value: 'Lead Time (days)', position: 'insideBottom', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<YAxis
dataKey="sellsOutInDays"
name="Sells Out In"
tick={{ fontSize: 11 }}
type="number"
label={{ value: 'Sells Out In (days)', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<ZAxis dataKey="revenue30d" range={[30, 300]} name="Revenue" />
{/* Diagonal risk line (y = x): products below this stock out before replenishment */}
<Scatter
data={(() => {
const max = products.length > 0 ? Math.max(...products.map(d => Math.max(d.leadTimeDays, d.sellsOutInDays))) : 100;
return [
{ leadTimeDays: 0, sellsOutInDays: 0, revenue30d: 0 },
{ leadTimeDays: max, sellsOutInDays: max, revenue30d: 0 },
];
})()}
line={{ stroke: '#9ca3af', strokeDasharray: '6 3', strokeWidth: 1.5 }}
shape={() => <circle r={0} />}
legendType="none"
isAnimationActive={false}
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as RiskProduct;
if (!d.title) return null; // skip diagonal line points
const buffer = d.sellsOutInDays - d.leadTimeDays;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm max-w-xs">
<p className="font-medium mb-1 truncate">{d.title}</p>
<p className="text-xs text-muted-foreground mb-1">{d.sku} ({d.abcClass})</p>
<p>Lead time: {d.leadTimeDays}d</p>
<p>Sells out in: {d.sellsOutInDays}d</p>
<p className={buffer <= 0 ? 'text-red-500 font-medium' : ''}>
Buffer: {buffer}d {buffer <= 0 ? '(AT RISK)' : ''}
</p>
<p>Stock: {d.currentStock} units</p>
<p>Velocity: {d.velocityDaily.toFixed(1)}/day</p>
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
</div>
);
}}
/>
<Scatter data={products} fillOpacity={0.7}>
{products.map((entry, i) => (
<Cell key={i} fill={getRiskColor(entry)} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,230 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, ScatterChart, Scatter, ZAxis } from 'recharts';
import config from '../../config';
import { useState, useEffect } from 'react';
interface VendorData {
performance: {
vendor: string;
salesVolume: number;
profitMargin: number;
stockTurnover: number;
productCount: number;
growth: number;
}[];
comparison?: {
vendor: string;
salesPerProduct: number;
averageMargin: number;
size: number;
}[];
trends?: {
vendor: string;
month: string;
sales: number;
}[];
}
export function VendorPerformance() {
const [vendorData, setVendorData] = useState<VendorData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Use plain fetch to bypass cache issues with React Query
const fetchData = async () => {
try {
setIsLoading(true);
// Add cache-busting parameter
const response = await fetch(`${config.apiUrl}/analytics/vendors?nocache=${Date.now()}`, {
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
});
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const rawData = await response.json();
if (!rawData || !rawData.performance) {
throw new Error('Invalid response format');
}
// Create a complete structure even if some parts are missing
const data: VendorData = {
performance: rawData.performance.map((vendor: any) => ({
vendor: vendor.vendor || '',
salesVolume: vendor.salesVolume !== null ? Number(vendor.salesVolume) : 0,
profitMargin: vendor.profitMargin !== null ? Number(vendor.profitMargin) : 0,
stockTurnover: vendor.stockTurnover !== null ? Number(vendor.stockTurnover) : 0,
productCount: Number(vendor.productCount) || 0,
growth: vendor.growth !== null ? Number(vendor.growth) : 0
})),
comparison: rawData.comparison?.map((vendor: any) => ({
vendor: vendor.vendor || '',
salesPerProduct: vendor.salesPerProduct !== null ? Number(vendor.salesPerProduct) : 0,
averageMargin: vendor.averageMargin !== null ? Number(vendor.averageMargin) : 0,
size: Number(vendor.size) || 0
})) || [],
trends: rawData.trends?.map((vendor: any) => ({
vendor: vendor.vendor || '',
month: vendor.month || '',
sales: Number(vendor.sales) || 0
})) || []
};
setVendorData(data);
} catch (err) {
console.error('Error fetching vendor data:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
if (isLoading) {
return <div>Loading vendor performance...</div>;
}
if (error || !vendorData) {
return <div className="text-red-500">Error loading vendor data: {error}</div>;
}
// Ensure we have at least the performance data
const sortedPerformance = vendorData.performance
.sort((a, b) => b.salesVolume - a.salesVolume)
.slice(0, 10);
// Use simplified version if comparison data is missing
const hasComparisonData = vendorData.comparison && vendorData.comparison.length > 0;
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Top Vendors by Sales Volume</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={sortedPerformance}>
<XAxis dataKey="vendor" />
<YAxis tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`} />
<Tooltip
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales Volume']}
/>
<Bar
dataKey="salesVolume"
fill="#60a5fa"
name="Sales Volume"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{hasComparisonData ? (
<Card>
<CardHeader>
<CardTitle>Vendor Performance Matrix</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<ScatterChart>
<XAxis
dataKey="salesPerProduct"
name="Sales per Product"
tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
/>
<YAxis
dataKey="averageMargin"
name="Average Margin"
tickFormatter={(value) => `${value.toFixed(0)}%`}
/>
<ZAxis
dataKey="size"
range={[50, 400]}
name="Product Count"
/>
<Tooltip
formatter={(value: number, name: string) => {
if (name === 'Sales per Product') return [`$${value.toLocaleString()}`, name];
if (name === 'Average Margin') return [`${value.toFixed(1)}%`, name];
return [value, name];
}}
/>
<Scatter
data={vendorData.comparison}
fill="#60a5fa"
name="Vendors"
/>
</ScatterChart>
</ResponsiveContainer>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Vendor Profit Margins</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={sortedPerformance}>
<XAxis dataKey="vendor" />
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
/>
<Bar
dataKey="profitMargin"
fill="#4ade80"
name="Profit Margin"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
</div>
<Card>
<CardHeader>
<CardTitle>Vendor Performance Details</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{sortedPerformance.map((vendor) => (
<div key={`${vendor.vendor}-${vendor.salesVolume}`} className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium">{vendor.vendor}</p>
<p className="text-sm text-muted-foreground">
{vendor.productCount} products
</p>
</div>
<div className="ml-4 text-right space-y-1">
<p className="text-sm font-medium">
${vendor.salesVolume.toLocaleString()} sales
</p>
<p className="text-sm text-muted-foreground">
{vendor.profitMargin.toFixed(1)}% margin
</p>
<p className="text-sm text-muted-foreground">
{vendor.stockTurnover.toFixed(1)}x turnover
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -8,7 +8,6 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Package,
Truck,
@@ -16,7 +15,6 @@ import {
XCircle,
DollarSign,
Activity,
AlertCircle,
FileText,
ChevronLeft,
ChevronRight,
@@ -66,7 +64,7 @@ const EVENT_TYPES = {
gradient: "from-red-800 to-red-700",
},
[METRIC_IDS.PAYMENT_REFUNDED]: {
label: "Payment Refunded",
label: "Payment Refund",
color: "bg-orange-200",
textColor: "text-orange-50",
iconColor: "text-orange-800",
@@ -94,22 +92,22 @@ const EVENT_ICONS = {
const LoadingState = () => (
<div className="flex gap-3 px-4">
{[...Array(6)].map((_, i) => (
<Card key={i} className="w-[210px] h-[125px] shrink-0 bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-sm">
<Card key={i} className="w-[210px] h-[125px] shrink-0 bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-md border-white/10">
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-3 py-2 pb-0">
<div className="flex items-baseline justify-between w-full pr-1">
<Skeleton className="h-4 w-20 bg-gray-700" />
<Skeleton className="h-3 w-14 bg-gray-700" />
<Skeleton className="h-3 w-20 bg-white/20" />
<Skeleton className="h-3 w-14 bg-white/20" />
</div>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-gray-300" />
<Skeleton className="h-4 w-4 bg-gray-700 relative rounded-full" />
<div className="absolute inset-0 rounded-full bg-white/20" />
<Skeleton className="h-4 w-4 bg-white/10 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-3 pt-1">
<div className="space-y-2">
<Skeleton className="h-7 w-36 bg-gray-700" />
<Skeleton className="h-7 w-36 bg-white/20" />
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-28 bg-gray-700" />
<Skeleton className="h-4 w-28 bg-white/10" />
</div>
</div>
</CardContent>
@@ -120,12 +118,12 @@ const LoadingState = () => (
// Empty State Component
const EmptyState = () => (
<Card className="w-[210px] h-[125px] bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-sm">
<Card className="w-[210px] h-[125px] bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-md border-white/10">
<CardContent className="flex flex-col items-center justify-center h-full text-center p-4">
<div className="bg-gray-800 rounded-full p-2 mb-2">
<Activity className="h-4 w-4 text-gray-400" />
<div className="bg-white/10 rounded-full p-2 mb-2">
<Activity className="h-4 w-4 text-gray-300" />
</div>
<p className="text-sm text-gray-400 font-medium">
<p className="text-xs font-medium text-gray-300 uppercase tracking-wide">
No recent activity
</p>
</CardContent>
@@ -141,14 +139,14 @@ const EventCard = ({ event }) => {
return (
<EventDialog event={event}>
<Card className={`w-[210px] border-none shrink-0 hover:brightness-110 cursor-pointer transition-colors h-[125px] bg-gradient-to-br ${eventType.gradient} backdrop-blur-sm`}>
<Card className={`w-[230px] border-white/10 shrink-0 hover:brightness-110 cursor-pointer transition-all h-[125px] bg-gradient-to-br ${eventType.gradient} backdrop-blur-md`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-3 py-2 pb-0">
<div className="flex items-baseline justify-between w-full pr-1">
<CardTitle className={`text-sm font-bold ${eventType.textColor}`}>
<CardTitle className={`text-xs font-medium ${eventType.textColor} uppercase tracking-wide`}>
{eventType.label}
</CardTitle>
{event.datetime && (
<CardDescription className={`text-xs ${eventType.textColor} opacity-80`}>
<CardDescription className={`text-xs ${eventType.textColor} opacity-70`}>
{format(new Date(event.datetime), "h:mm a")}
</CardDescription>
)}

View File

@@ -85,7 +85,7 @@ const MiniRealtimeAnalytics = () => {
);
}
if (loading) {
if (loading && !basicData.byMinute?.length) {
return (
<div>
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
@@ -141,18 +141,18 @@ const MiniRealtimeAnalytics = () => {
<DashboardStatCardMini
title="Last 30 Minutes"
value={basicData.last30MinUsers}
description="Active users"
subtitle="Active users"
gradient="sky"
icon={Users}
iconBackground="bg-sky-300"
iconBackground="bg-sky-400"
/>
<DashboardStatCardMini
title="Last 5 Minutes"
value={basicData.last5MinUsers}
description="Active users"
subtitle="Active users"
gradient="sky"
icon={Activity}
iconBackground="bg-sky-300"
iconBackground="bg-sky-400"
/>
</div>

View File

@@ -140,31 +140,20 @@ const MiniSalesChart = ({ className = "" }) => {
);
}
// Helper to calculate trend direction
const getRevenueTrend = () => {
const current = summaryStats.periodProgress < 100
? (projection?.projectedRevenue || summaryStats.totalRevenue)
: summaryStats.totalRevenue;
return current >= summaryStats.prevRevenue ? "up" : "down";
};
// Helper to calculate trend values (positive = up, negative = down)
const getRevenueTrendValue = () => {
const current = summaryStats.periodProgress < 100
? (projection?.projectedRevenue || summaryStats.totalRevenue)
: summaryStats.totalRevenue;
return `${Math.abs(Math.round((current - summaryStats.prevRevenue) / summaryStats.prevRevenue * 100))}%`;
};
const getOrdersTrend = () => {
const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress));
const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders;
return current >= summaryStats.prevOrders ? "up" : "down";
if (!summaryStats.prevRevenue) return 0;
return ((current - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100;
};
const getOrdersTrendValue = () => {
const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress));
const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders;
return `${Math.abs(Math.round((current - summaryStats.prevOrders) / summaryStats.prevOrders * 100))}%`;
if (!summaryStats.prevOrders) return 0;
return ((current - summaryStats.prevOrders) / summaryStats.prevOrders) * 100;
};
if (loading && !data) {
@@ -190,7 +179,7 @@ const MiniSalesChart = ({ className = "" }) => {
<div className="space-y-2">
{/* Stat Cards */}
<div className="grid grid-cols-2 gap-2">
{loading ? (
{loading && !data?.length ? (
<>
<DashboardStatCardMiniSkeleton gradient="slate" />
<DashboardStatCardMiniSkeleton gradient="slate" />
@@ -200,13 +189,10 @@ const MiniSalesChart = ({ className = "" }) => {
<DashboardStatCardMini
title="30 Days Revenue"
value={formatCurrency(summaryStats.totalRevenue, false)}
description={`Prev: ${formatCurrency(summaryStats.prevRevenue, false)}`}
trend={{
direction: getRevenueTrend(),
value: getRevenueTrendValue(),
}}
subtitle={`Prev: ${formatCurrency(summaryStats.prevRevenue, false)}`}
trend={{ value: getRevenueTrendValue() }}
icon={PiggyBank}
iconBackground="bg-emerald-300"
iconBackground="bg-emerald-400"
gradient="slate"
className={!visibleMetrics.revenue ? 'opacity-50' : ''}
onClick={() => toggleMetric('revenue')}
@@ -214,13 +200,10 @@ const MiniSalesChart = ({ className = "" }) => {
<DashboardStatCardMini
title="30 Days Orders"
value={summaryStats.totalOrders.toLocaleString()}
description={`Prev: ${summaryStats.prevOrders.toLocaleString()}`}
trend={{
direction: getOrdersTrend(),
value: getOrdersTrendValue(),
}}
subtitle={`Prev: ${summaryStats.prevOrders.toLocaleString()}`}
trend={{ value: getOrdersTrendValue() }}
icon={Truck}
iconBackground="bg-blue-300"
iconBackground="bg-blue-400"
gradient="slate"
className={!visibleMetrics.orders ? 'opacity-50' : ''}
onClick={() => toggleMetric('orders')}
@@ -233,7 +216,7 @@ const MiniSalesChart = ({ className = "" }) => {
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
<CardContent className="p-4">
<div className="h-[216px]">
{loading ? (
{loading && !data?.length ? (
<ChartSkeleton height="sm" withCard={false} />
) : (
<ResponsiveContainer width="100%" height="100%">

View File

@@ -30,7 +30,6 @@ import {
ShippingDetails,
DetailDialog,
formatCurrency,
formatPercentage,
} from "./StatCards";
import {
DashboardStatCardMini,
@@ -112,8 +111,14 @@ const MiniStatCards = ({
const calculateOrderTrend = useCallback(() => {
if (!stats?.prevPeriodOrders) return null;
return calculateTrend(stats.orderCount, stats.prevPeriodOrders);
}, [stats, calculateTrend]);
// If period is incomplete, use projected orders for fair comparison
const currentOrders = stats.periodProgress < 100
? (projection?.projectedOrders || Math.round(stats.orderCount / (stats.periodProgress / 100)))
: stats.orderCount;
return calculateTrend(currentOrders, stats.prevPeriodOrders);
}, [stats, projection, calculateTrend]);
const calculateAOVTrend = useCallback(() => {
if (!stats?.prevPeriodAOV) return null;
@@ -284,18 +289,18 @@ const MiniStatCards = ({
<DashboardStatCardMini
title="Today's Revenue"
value={formatCurrency(stats?.revenue || 0)}
description={
subtitle={
stats?.periodProgress < 100
? `Proj: ${formatCurrency(projection?.projectedRevenue || stats.projectedRevenue)}`
: undefined
}
trend={
revenueTrend?.trend && !projectionLoading
? { direction: revenueTrend.trend, value: formatPercentage(revenueTrend.value) }
? { value: revenueTrend.trend === "up" ? revenueTrend.value : -revenueTrend.value }
: undefined
}
icon={DollarSign}
iconBackground="bg-emerald-300"
iconBackground="bg-emerald-400"
gradient="emerald"
className="h-[150px]"
onClick={() => setSelectedMetric("revenue")}
@@ -304,14 +309,16 @@ const MiniStatCards = ({
<DashboardStatCardMini
title="Today's Orders"
value={stats?.orderCount}
description={`${stats?.itemCount} total items`}
subtitle={`${stats?.itemCount} total items`}
trend={
orderTrend?.trend
? { direction: orderTrend.trend, value: formatPercentage(orderTrend.value) }
projectionLoading && stats?.periodProgress < 100
? undefined
: orderTrend?.trend
? { value: orderTrend.trend === "up" ? orderTrend.value : -orderTrend.value }
: undefined
}
icon={ShoppingCart}
iconBackground="bg-blue-300"
iconBackground="bg-blue-400"
gradient="blue"
className="h-[150px]"
onClick={() => setSelectedMetric("orders")}
@@ -321,14 +328,14 @@ const MiniStatCards = ({
title="Today's AOV"
value={stats?.averageOrderValue?.toFixed(2)}
valuePrefix="$"
description={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items/order`}
trend={
aovTrend?.trend
? { direction: aovTrend.trend, value: formatPercentage(aovTrend.value) }
? { value: aovTrend.trend === "up" ? aovTrend.value : -aovTrend.value }
: undefined
}
icon={CircleDollarSign}
iconBackground="bg-violet-300"
iconBackground="bg-violet-400"
gradient="violet"
className="h-[150px]"
onClick={() => setSelectedMetric("average_order")}
@@ -337,9 +344,9 @@ const MiniStatCards = ({
<DashboardStatCardMini
title="Shipped Today"
value={stats?.shipping?.shippedCount || 0}
description={`${stats?.shipping?.locations?.total || 0} locations`}
subtitle={`${stats?.shipping?.locations?.total || 0} locations`}
icon={Package}
iconBackground="bg-orange-300"
iconBackground="bg-orange-400"
gradient="orange"
className="h-[150px]"
onClick={() => setSelectedMetric("shipping")}

View File

@@ -21,6 +21,8 @@ const Navigation = () => {
{ id: "stats", label: "Statistics", permission: "dashboard:stats" },
{ id: "realtime", label: "Realtime", permission: "dashboard:realtime" },
{ id: "financial", label: "Financial", permission: "dashboard:financial" },
{ id: "payroll-metrics", label: "Payroll", permission: "dashboard:payroll" },
{ id: "operations-metrics", label: "Operations", permission: "dashboard:operations" },
{ id: "feed", label: "Event Feed", permission: "dashboard:feed" },
{ id: "sales", label: "Sales Chart", permission: "dashboard:sales" },
{ id: "products", label: "Top Products", permission: "dashboard:products" },

View File

@@ -9,13 +9,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import {
Table,
@@ -53,6 +46,7 @@ import {
TOOLTIP_STYLES,
METRIC_COLORS,
} from "@/components/dashboard/shared";
import { Tooltip as TooltipUI, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
type ComparisonValue = {
absolute: number | null;
@@ -512,6 +506,9 @@ const OperationsMetrics = () => {
}, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]);
const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]);
const hasOrdersMetric = metrics.ordersPicked || metrics.ordersShipped;
const hasPiecesMetric = metrics.piecesPicked || metrics.piecesShipped;
const useDualAxis = hasOrdersMetric && hasPiecesMetric;
const hasData = chartData.length > 0;
const handleGroupByChange = (value: string) => {
@@ -577,80 +574,6 @@ const OperationsMetrics = () => {
const headerActions = !error ? (
<>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9" disabled={loading || !data?.byEmployee}>
Details
</Button>
</DialogTrigger>
<DialogContent className={`p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col ${CARD_STYLES.base}`}>
<DialogHeader className="flex-none">
<DialogTitle className="text-foreground">
Operations Details
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto mt-6 space-y-6">
<div>
<h3 className="text-sm font-medium mb-2">Picking by Employee</h3>
<div className={`rounded-lg border ${CARD_STYLES.base}`}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap px-4">Employee</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Tickets</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Orders</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Pieces</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Hours</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Speed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.byEmployee?.picking?.map((emp) => (
<TableRow key={emp.employeeId}>
<TableCell className="px-4">{emp.name}</TableCell>
<TableCell className="text-right px-4">{formatNumber(emp.ticketCount)}</TableCell>
<TableCell className="text-right px-4">{formatNumber(emp.ordersPicked)}</TableCell>
<TableCell className="text-right px-4">{formatNumber(emp.piecesPicked)}</TableCell>
<TableCell className="text-right px-4">{formatHours(emp.pickingHours || 0)}</TableCell>
<TableCell className="text-right px-4">
{emp.avgPickingSpeed != null ? `${formatNumber(emp.avgPickingSpeed, 1)}/h` : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{data?.byEmployee?.shipping && data.byEmployee.shipping.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2">Shipping by Employee</h3>
<div className={`rounded-lg border ${CARD_STYLES.base}`}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap px-4">Employee</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Orders</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Pieces</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.byEmployee.shipping.map((emp) => (
<TableRow key={emp.employeeId}>
<TableCell className="px-4">{emp.name}</TableCell>
<TableCell className="text-right px-4">{formatNumber(emp.ordersShipped)}</TableCell>
<TableCell className="text-right px-4">{formatNumber(emp.piecesShipped)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
<PeriodSelectionPopover
open={isPeriodPopoverOpen}
onOpenChange={setIsPeriodPopoverOpen}
@@ -671,7 +594,7 @@ const OperationsMetrics = () => {
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
<CardContent className="p-6 pt-0">
{!error && (
loading ? (
<SkeletonStats />
@@ -717,7 +640,14 @@ const OperationsMetrics = () => {
)}
{loading ? (
<ChartSkeleton type="area" height="default" withCard={false} />
<div className="flex flex-col lg:flex-row gap-6 mt-4">
<div className="w-full lg:w-[45%]">
<LeaderboardTableSkeleton />
</div>
<div className="w-full lg:w-[55%]">
<ChartSkeleton type="area" height="md" withCard={false} />
</div>
</div>
) : error ? (
<DashboardErrorState error={`Failed to load operations data: ${error}`} className="mx-0 my-0" />
) : !hasData ? (
@@ -727,7 +657,14 @@ const OperationsMetrics = () => {
description="Try selecting a different time range"
/>
) : (
<div className={`h-[280px] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
<div className="flex flex-col lg:flex-row gap-6 mt-4">
<div className="w-full lg:w-[45%]">
<OperationsLeaderboard
picking={data?.byEmployee?.picking ?? []}
shipping={data?.byEmployee?.shipping ?? []}
/>
</div>
<div className={`h-[300px] w-full lg:w-[55%] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
{!hasActiveMetrics ? (
<DashboardEmptyState
icon={TrendingUp}
@@ -750,15 +687,26 @@ const OperationsMetrics = () => {
tick={{ fill: "currentColor" }}
/>
<YAxis
yAxisId="left"
tickFormatter={(value: number) => formatNumber(value)}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
{useDualAxis && (
<YAxis
yAxisId="right"
orientation="right"
tickFormatter={(value: number) => formatNumber(value)}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
)}
<Tooltip content={<OperationsTooltip />} />
<Legend formatter={(value: string) => SERIES_LABELS[value as ChartSeriesKey] ?? value} />
{metrics.ordersPicked && (
<Area
yAxisId="left"
type="monotone"
dataKey="ordersPicked"
name={SERIES_LABELS.ordersPicked}
@@ -769,6 +717,7 @@ const OperationsMetrics = () => {
)}
{metrics.ordersShipped && (
<Line
yAxisId="left"
type="monotone"
dataKey="ordersShipped"
name={SERIES_LABELS.ordersShipped}
@@ -781,6 +730,7 @@ const OperationsMetrics = () => {
)}
{metrics.piecesPicked && (
<Line
yAxisId={useDualAxis ? "right" : "left"}
type="monotone"
dataKey="piecesPicked"
name={SERIES_LABELS.piecesPicked}
@@ -794,6 +744,7 @@ const OperationsMetrics = () => {
)}
{metrics.piecesShipped && (
<Line
yAxisId={useDualAxis ? "right" : "left"}
type="monotone"
dataKey="piecesShipped"
name={SERIES_LABELS.piecesShipped}
@@ -809,6 +760,7 @@ const OperationsMetrics = () => {
</ResponsiveContainer>
)}
</div>
</div>
)}
</CardContent>
</Card>
@@ -910,4 +862,170 @@ const OperationsTooltip = ({ active, payload, label }: TooltipProps<number, stri
);
};
function LeaderboardTableSkeleton() {
return (
<div className={`h-[280px] rounded-lg border ${CARD_STYLES.base} overflow-hidden`}>
<div className="p-3 border-b bg-muted/30">
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
</div>
<div className="p-2 space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-2 py-1.5">
<div className="h-5 w-5 bg-muted rounded-full animate-pulse" />
<div className="h-3 w-20 bg-muted rounded animate-pulse" />
<div className="flex-1" />
<div className="h-3 w-10 bg-muted rounded animate-pulse" />
<div className="h-3 w-10 bg-muted rounded animate-pulse" />
</div>
))}
</div>
</div>
);
}
type LeaderboardEntry = {
employeeId: number;
name: string;
ordersPicked: number;
piecesPicked: number;
ordersShipped: number;
piecesShipped: number;
pickingHours: number;
pickingSpeed: number | null;
productivity: number;
};
function OperationsLeaderboard({
picking,
shipping,
maxRows = 20,
}: {
picking: EmployeePickingEntry[];
shipping: EmployeeShippingEntry[];
maxRows?: number;
}) {
const leaderboard = useMemo<LeaderboardEntry[]>(() => {
const employeeMap = new Map<number, LeaderboardEntry>();
picking.forEach((emp) => {
employeeMap.set(emp.employeeId, {
employeeId: emp.employeeId,
name: emp.name,
ordersPicked: emp.ordersPicked,
piecesPicked: emp.piecesPicked,
ordersShipped: 0,
piecesShipped: 0,
pickingHours: emp.pickingHours || 0,
pickingSpeed: emp.avgPickingSpeed,
productivity: emp.pickingHours > 0 ? emp.ordersPicked / emp.pickingHours : 0,
});
});
shipping.forEach((emp) => {
const existing = employeeMap.get(emp.employeeId);
if (existing) {
existing.ordersShipped = emp.ordersShipped;
existing.piecesShipped = emp.piecesShipped;
} else {
employeeMap.set(emp.employeeId, {
employeeId: emp.employeeId,
name: emp.name,
ordersPicked: 0,
piecesPicked: 0,
ordersShipped: emp.ordersShipped,
piecesShipped: emp.piecesShipped,
pickingHours: 0,
pickingSpeed: null,
productivity: 0,
});
}
});
return Array.from(employeeMap.values())
.sort((a, b) => b.piecesPicked - a.piecesPicked)
.slice(0, maxRows);
}, [picking, shipping, maxRows]);
const totalEmployees = Math.max(picking.length, shipping.length);
if (leaderboard.length === 0) {
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} flex items-center justify-center`}>
<p className="text-sm text-muted-foreground">No employee data</p>
</div>
);
}
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} overflow-hidden flex flex-col`}>
<div className="p-3 border-b bg-muted/30 flex-none">
<h4 className="text-sm font-medium">Top Performers</h4>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs px-2 w-8" />
<TableHead className="h-8 text-xs px-2">Name</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">Picked</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">Shipped</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">
<TooltipUI>
<TooltipTrigger asChild>
<span className="cursor-help">Hours</span>
</TooltipTrigger>
<TooltipContent>
<p>Time spent picking only</p>
</TooltipContent>
</TooltipUI>
</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">Speed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{leaderboard.map((emp, index) => (
<TableRow key={emp.employeeId}>
<TableCell className="py-1.5 px-2 w-8 text-xs text-muted-foreground text-center">
{index + 1}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs font-medium truncate max-w-[100px]">
{emp.name}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right">
<span className="font-medium">{formatNumber(emp.piecesPicked)}</span>
<span className="text-muted-foreground ml-1 text-[10px]">
({formatNumber(emp.ordersPicked)})
</span>
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right text-muted-foreground">
{emp.ordersShipped > 0 ? formatNumber(emp.ordersShipped) : "—"}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right text-muted-foreground">
{emp.pickingHours > 0 ? formatHours(emp.pickingHours) : "—"}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right">
{emp.pickingSpeed != null ? (
<span className="text-emerald-600 dark:text-emerald-400 font-medium">
{formatNumber(emp.pickingSpeed, 0)}/h
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{totalEmployees > maxRows && (
<div className="p-2 border-t text-center flex-none">
<span className="text-xs text-muted-foreground">
+{totalEmployees - maxRows} more employees
</span>
</div>
)}
</div>
);
}
export default OperationsMetrics;

View File

@@ -1,14 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useCallback } from "react";
import { acotService } from "@/services/dashboard/acotService";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -19,17 +19,17 @@ import {
} from "@/components/ui/table";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
ComposedChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { TooltipProps } from "recharts";
import { Clock, Users, AlertTriangle, ChevronLeft, ChevronRight, Calendar } from "lucide-react";
import { Clock, Users, AlertTriangle, ChevronLeft, ChevronRight, Calendar, TrendingUp } from "lucide-react";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
@@ -114,9 +114,20 @@ type PayrollMetricsResponse = {
byWeek: WeekSummary[];
};
type PeriodCountOption = 1 | 3 | 6 | 12;
const PERIOD_COUNT_OPTIONS: { value: PeriodCountOption; label: string }[] = [
{ value: 1, label: "1 period" },
{ value: 3, label: "3 periods" },
{ value: 6, label: "6 periods" },
{ value: 12, label: "12 periods" },
];
const chartColors = {
regular: METRIC_COLORS.orders,
overtime: METRIC_COLORS.expense,
hours: METRIC_COLORS.profit,
fte: METRIC_COLORS.secondary,
};
const formatNumber = (value: number, decimals = 0) => {
@@ -132,13 +143,56 @@ const formatHours = (value: number) => {
return `${value.toFixed(1)}h`;
};
// Calculate the start date for a pay period N periods back from a reference date
function getPayPeriodStart(referenceStart: string, periodsBack: number): string {
const date = new Date(referenceStart + "T00:00:00");
date.setDate(date.getDate() - (periodsBack * 14));
return date.toISOString().split("T")[0];
}
// Format a pay period label from its start date
function formatPeriodLabel(start: string): string {
const startDate = new Date(start + "T00:00:00");
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 13);
const startStr = startDate.toLocaleDateString("en-US", { month: "short", day: "numeric" });
const endStr = endDate.toLocaleDateString("en-US", { month: "short", day: "numeric" });
return `${startStr} ${endStr}`;
}
const PayrollMetrics = () => {
const [data, setData] = useState<PayrollMetricsResponse | null>(null);
const [historicalData, setHistoricalData] = useState<PayrollMetricsResponse[]>([]);
const [currentPeriodData, setCurrentPeriodData] = useState<PayrollMetricsResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [periodCount, setPeriodCount] = useState<PeriodCountOption>(3);
const [currentPayPeriodStart, setCurrentPayPeriodStart] = useState<string | null>(null);
// Fetch data
// Fetch historical data for multiple periods
const fetchHistoricalData = useCallback(async (referenceStart: string, count: PeriodCountOption) => {
const periodStarts: string[] = [];
for (let i = 0; i < count; i++) {
periodStarts.push(getPayPeriodStart(referenceStart, i));
}
const results = await Promise.all(
periodStarts.map(async (start) => {
try {
// @ts-expect-error - acotService is a JS file
const response = await acotService.getPayrollMetrics({ payPeriodStart: start });
return response as PayrollMetricsResponse;
} catch {
return null;
}
})
);
return results.filter((r): r is PayrollMetricsResponse => r !== null);
}, []);
// Initial fetch - get current period first to establish reference
useEffect(() => {
let cancelled = false;
@@ -147,18 +201,24 @@ const PayrollMetrics = () => {
setError(null);
try {
const params: Record<string, string> = {};
if (currentPayPeriodStart) {
params.payPeriodStart = currentPayPeriodStart;
// First, get the current period to establish a reference point
// @ts-expect-error - acotService is a JS file
const currentResponse = (await acotService.getPayrollMetrics({})) as PayrollMetricsResponse;
if (cancelled) return;
setCurrentPeriodData(currentResponse);
if (currentResponse.payPeriod?.start) {
const referenceStart = currentPayPeriodStart || currentResponse.payPeriod.start;
if (!currentPayPeriodStart) {
setCurrentPayPeriodStart(currentResponse.payPeriod.start);
}
// @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type
const response = (await acotService.getPayrollMetrics(params)) as PayrollMetricsResponse;
// Fetch historical data
const historical = await fetchHistoricalData(referenceStart, periodCount);
if (!cancelled) {
setData(response);
// Update the current pay period start if not set (first load)
if (!currentPayPeriodStart && response.payPeriod?.start) {
setCurrentPayPeriodStart(response.payPeriod.start);
setHistoricalData(historical);
}
}
} catch (err: unknown) {
@@ -184,154 +244,227 @@ const PayrollMetrics = () => {
void fetchData();
return () => { cancelled = true; };
}, [currentPayPeriodStart]);
}, [currentPayPeriodStart, periodCount, fetchHistoricalData]);
const isAtCurrentPeriod = !currentPayPeriodStart ||
currentPayPeriodStart === currentPeriodData?.payPeriod?.start;
const navigatePeriod = (direction: "prev" | "next") => {
if (!data?.payPeriod?.start) return;
if (!currentPayPeriodStart) return;
// Calculate the new pay period start by adding/subtracting 14 days
const currentStart = new Date(data.payPeriod.start);
const offset = direction === "prev" ? -14 : 14;
const currentStart = new Date(currentPayPeriodStart + "T00:00:00");
const offset = direction === "prev" ? -(14 * periodCount) : (14 * periodCount);
currentStart.setDate(currentStart.getDate() + offset);
setCurrentPayPeriodStart(currentStart.toISOString().split("T")[0]);
const newStart = currentStart.toISOString().split("T")[0];
// If navigating forward would reach or pass the current period, snap to current
if (direction === "next" && currentPeriodData?.payPeriod?.start && newStart >= currentPeriodData.payPeriod.start) {
setCurrentPayPeriodStart(null);
return;
}
setCurrentPayPeriodStart(newStart);
};
const goToCurrentPeriod = () => {
setCurrentPayPeriodStart(null); // null triggers loading current period
setCurrentPayPeriodStart(null);
};
const cards = useMemo(() => {
if (!data?.totals) return [];
// Aggregate stats across all historical periods
const aggregateStats = useMemo(() => {
if (historicalData.length === 0) return null;
const totals = data.totals;
const comparison = data.comparison ?? {};
const totals = historicalData.reduce(
(acc, period) => ({
hours: acc.hours + (period.totals?.hours || 0),
regularHours: acc.regularHours + (period.totals?.regularHours || 0),
overtimeHours: acc.overtimeHours + (period.totals?.overtimeHours || 0),
fte: acc.fte + (period.totals?.fte || 0),
activeEmployees: acc.activeEmployees + (period.totals?.activeEmployees || 0),
}),
{ hours: 0, regularHours: 0, overtimeHours: 0, fte: 0, activeEmployees: 0 }
);
const avgFte = totals.fte / historicalData.length;
const avgActiveEmployees = totals.activeEmployees / historicalData.length;
const avgHoursPerPeriod = totals.hours / historicalData.length;
// Calculate trend (compare first half to second half)
const midpoint = Math.floor(historicalData.length / 2);
const recentPeriods = historicalData.slice(0, midpoint);
const olderPeriods = historicalData.slice(midpoint);
const recentAvgHours = recentPeriods.reduce((sum, p) => sum + (p.totals?.hours || 0), 0) / recentPeriods.length;
const olderAvgHours = olderPeriods.reduce((sum, p) => sum + (p.totals?.hours || 0), 0) / olderPeriods.length;
const hoursTrend = olderAvgHours > 0 ? ((recentAvgHours - olderAvgHours) / olderAvgHours) * 100 : 0;
const recentAvgOT = recentPeriods.reduce((sum, p) => sum + (p.totals?.overtimeHours || 0), 0) / recentPeriods.length;
const olderAvgOT = olderPeriods.reduce((sum, p) => sum + (p.totals?.overtimeHours || 0), 0) / olderPeriods.length;
const otTrend = olderAvgOT > 0 ? ((recentAvgOT - olderAvgOT) / olderAvgOT) * 100 : 0;
return {
totalHours: totals.hours,
totalRegular: totals.regularHours,
totalOvertime: totals.overtimeHours,
avgFte,
avgActiveEmployees,
avgHoursPerPeriod,
hoursTrend,
otTrend,
periodCount: historicalData.length,
};
}, [historicalData]);
const cards = useMemo(() => {
if (!aggregateStats) return [];
return [
{
key: "hours",
title: "Total Hours",
value: formatHours(totals.hours),
description: `${formatHours(totals.regularHours)} regular`,
trendValue: comparison.hours?.percentage,
value: formatHours(aggregateStats.totalHours),
description: `${formatHours(aggregateStats.avgHoursPerPeriod)} avg/period`,
trendValue: aggregateStats.hoursTrend,
iconColor: "blue" as const,
tooltip: "Total hours worked by all employees in this pay period.",
tooltip: `Total hours across ${aggregateStats.periodCount} pay periods.`,
},
{
key: "overtime",
title: "Overtime",
value: formatHours(totals.overtimeHours),
description: totals.overtimeHours > 0
? `${formatNumber((totals.overtimeHours / totals.hours) * 100, 1)}% of total`
title: "Total Overtime",
value: formatHours(aggregateStats.totalOvertime),
description: aggregateStats.totalOvertime > 0
? `${formatNumber((aggregateStats.totalOvertime / aggregateStats.totalHours) * 100, 1)}% of total`
: "No overtime",
trendValue: comparison.overtimeHours?.percentage,
trendValue: aggregateStats.otTrend,
trendInverted: true,
iconColor: totals.overtimeHours > 0 ? "orange" as const : "emerald" as const,
tooltip: "Hours exceeding 40 per employee per week.",
iconColor: aggregateStats.totalOvertime > 0 ? "orange" as const : "emerald" as const,
tooltip: "Total overtime hours across all periods.",
},
{
key: "fte",
title: "FTE",
value: formatNumber(totals.fte, 2),
description: `${formatNumber(totals.activeEmployees)} employees`,
trendValue: comparison.fte?.percentage,
title: "Avg FTE",
value: formatNumber(aggregateStats.avgFte, 2),
description: `${formatNumber(aggregateStats.avgActiveEmployees, 0)} avg employees`,
iconColor: "emerald" as const,
tooltip: "Full-Time Equivalents (80 hours = 1 FTE for 2-week period).",
tooltip: "Average Full-Time Equivalents per period.",
},
{
key: "avgHours",
title: "Avg Hours",
value: formatHours(totals.avgHoursPerEmployee),
description: "Per employee",
key: "periods",
title: "Periods",
value: String(aggregateStats.periodCount),
description: `${aggregateStats.periodCount * 2} weeks`,
iconColor: "purple" as const,
tooltip: "Average hours worked per active employee in this pay period.",
tooltip: "Number of pay periods shown.",
},
];
}, [data]);
}, [aggregateStats]);
// Chart data showing trends across periods (or week breakdown for single period)
const chartData = useMemo(() => {
if (!data?.byWeek) return [];
if (historicalData.length === 0) return [];
return data.byWeek.map((week) => ({
name: `Week ${week.week}`,
label: formatWeekRange(week.start, week.end),
regular: week.regular,
overtime: week.overtime,
total: week.hours,
// Single period: show week-by-week breakdown
if (historicalData.length === 1) {
const period = historicalData[0];
return (period.byWeek || []).map((week, i) => {
const weekInfo = i === 0 ? period.payPeriod.week1 : period.payPeriod.week2;
const startDate = new Date((weekInfo?.start || week.start) + "T00:00:00");
const endDate = new Date((weekInfo?.end || week.end) + "T00:00:00");
const label = `${startDate.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${endDate.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`;
return {
label,
periodStart: week.start,
regular: week.regular || 0,
overtime: week.overtime || 0,
total: week.hours || 0,
fte: period.totals?.fte || 0,
employees: period.totals?.activeEmployees || 0,
};
});
}
// Multiple periods: show period-by-period trend (oldest first, left to right)
return [...historicalData].reverse().map((period) => ({
label: formatPeriodLabel(period.payPeriod.start),
periodStart: period.payPeriod.start,
regular: period.totals?.regularHours || 0,
overtime: period.totals?.overtimeHours || 0,
total: period.totals?.hours || 0,
fte: period.totals?.fte || 0,
employees: period.totals?.activeEmployees || 0,
}));
}, [data]);
}, [historicalData]);
const hasData = data?.byWeek && data.byWeek.length > 0;
// Aggregate employee data across ALL displayed periods
const aggregatedEmployees = useMemo((): AggregatedEmployee[] => {
if (historicalData.length === 0) return [];
// Single period: include week-level breakdown
if (historicalData.length === 1) {
return (historicalData[0].byEmployee || []).map((emp) => ({
employeeId: emp.employeeId,
name: emp.name,
totalHours: emp.totalHours,
overtimeHours: emp.overtimeHours,
regularHours: emp.regularHours,
periodCount: 1,
week1Hours: emp.week1Hours,
week2Hours: emp.week2Hours,
week1Overtime: emp.week1Overtime,
week2Overtime: emp.week2Overtime,
}));
}
// Multiple periods: aggregate across all periods
const employeeMap = new Map<number, AggregatedEmployee>();
historicalData.forEach((period) => {
(period.byEmployee || []).forEach((emp) => {
const existing = employeeMap.get(emp.employeeId);
if (existing) {
existing.totalHours += emp.totalHours;
existing.overtimeHours += emp.overtimeHours;
existing.regularHours += emp.regularHours;
existing.periodCount += 1;
} else {
employeeMap.set(emp.employeeId, {
employeeId: emp.employeeId,
name: emp.name,
totalHours: emp.totalHours,
overtimeHours: emp.overtimeHours,
regularHours: emp.regularHours,
periodCount: 1,
});
}
});
});
return Array.from(employeeMap.values());
}, [historicalData]);
const hasData = chartData.length > 0;
const headerActions = !error ? (
<div className="flex items-center gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9" disabled={loading || !data?.byEmployee}>
Details
</Button>
</DialogTrigger>
<DialogContent className={`p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col ${CARD_STYLES.base}`}>
<DialogHeader className="flex-none">
<DialogTitle className="text-foreground">
Employee Hours - {data?.payPeriod?.label}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto mt-6">
<div className={`rounded-lg border ${CARD_STYLES.base}`}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap px-4">Employee</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Week 1</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Week 2</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Total</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Overtime</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.byEmployee?.map((emp) => (
<TableRow key={emp.employeeId}>
<TableCell className="px-4">{emp.name}</TableCell>
<TableCell className="text-right px-4">
<span className={emp.week1Overtime > 0 ? "text-orange-600 dark:text-orange-400 font-medium" : ""}>
{formatHours(emp.week1Hours)}
{emp.week1Overtime > 0 && (
<span className="ml-1 text-xs">
(+{formatHours(emp.week1Overtime)} OT)
</span>
)}
</span>
</TableCell>
<TableCell className="text-right px-4">
<span className={emp.week2Overtime > 0 ? "text-orange-600 dark:text-orange-400 font-medium" : ""}>
{formatHours(emp.week2Hours)}
{emp.week2Overtime > 0 && (
<span className="ml-1 text-xs">
(+{formatHours(emp.week2Overtime)} OT)
</span>
)}
</span>
</TableCell>
<TableCell className="text-right px-4 font-medium">
{formatHours(emp.totalHours)}
</TableCell>
<TableCell className="text-right px-4">
{emp.overtimeHours > 0 ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">
{formatHours(emp.overtimeHours)}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
<div className="flex items-center gap-2 flex-wrap">
<Select
value={String(periodCount)}
onValueChange={(value) => {
setPeriodCount(Number(value) as PeriodCountOption);
setCurrentPayPeriodStart(null);
}}
disabled={loading}
>
<SelectTrigger className="h-9 w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PERIOD_COUNT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</TableBody>
</Table>
</div>
</div>
</DialogContent>
</Dialog>
</SelectContent>
</Select>
<div className="flex items-center gap-1">
<Button
@@ -345,19 +478,19 @@ const PayrollMetrics = () => {
</Button>
<Button
variant="outline"
className="h-9 px-3 min-w-[180px]"
className="h-9 px-3 min-w-[120px]"
onClick={goToCurrentPeriod}
disabled={loading || data?.payPeriod?.isCurrent}
disabled={loading || isAtCurrentPeriod}
>
<Calendar className="h-4 w-4 mr-2" />
{loading ? "Loading..." : data?.payPeriod?.label || "Loading..."}
{loading ? "Loading..." : "Current"}
</Button>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() => navigatePeriod("next")}
disabled={loading || data?.payPeriod?.isCurrent}
disabled={loading || isAtCurrentPeriod}
>
<ChevronRight className="h-4 w-4" />
</Button>
@@ -373,7 +506,7 @@ const PayrollMetrics = () => {
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
<CardContent className="p-6 pt-0">
{!error && (
loading ? (
<SkeletonStats />
@@ -383,7 +516,14 @@ const PayrollMetrics = () => {
)}
{loading ? (
<ChartSkeleton type="bar" height="default" withCard={false} />
<div className="flex flex-col lg:flex-row gap-6">
<div className="w-full lg:w-[65%]">
<ChartSkeleton type="bar" height="md" withCard={false} />
</div>
<div className="w-full lg:w-[35%]">
<EmployeeTableSkeleton />
</div>
</div>
) : error ? (
<DashboardErrorState error={`Failed to load payroll data: ${error}`} className="mx-0 my-0" />
) : !hasData ? (
@@ -393,70 +533,76 @@ const PayrollMetrics = () => {
description="Try selecting a different pay period"
/>
) : (
<div className={`h-[280px] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
<div className="flex flex-col lg:flex-row gap-6">
<div className={`h-[300px] w-full lg:w-[65%] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 20, right: 20, left: 20, bottom: 5 }}>
<ComposedChart data={chartData} margin={{ top: 20, right: 20, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="label"
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
tick={{ fill: "currentColor", fontSize: 10 }}
angle={-45}
textAnchor="end"
height={60}
interval={0}
/>
<YAxis
yAxisId="hours"
tickFormatter={(value: number) => `${value}h`}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
<Tooltip content={<PayrollTooltip />} />
<YAxis
yAxisId="fte"
orientation="right"
tickFormatter={(value: number) => value.toFixed(1)}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
<Tooltip content={<PayrollTrendTooltip />} />
<Legend />
<Bar
yAxisId="hours"
dataKey="regular"
name="Regular Hours"
stackId="hours"
fill={chartColors.regular}
/>
<Bar
yAxisId="hours"
dataKey="overtime"
name="Overtime"
stackId="hours"
fill={chartColors.overtime}
>
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.overtime > 0 ? chartColors.overtime : chartColors.regular}
/>
))}
</Bar>
</BarChart>
<Line
yAxisId="fte"
type="monotone"
dataKey="fte"
name="FTE"
stroke={chartColors.fte}
strokeWidth={2}
dot={{ fill: chartColors.fte, r: 3 }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="w-full lg:w-[35%]">
<PayrollEmployeeSummary
employees={aggregatedEmployees}
periodCount={historicalData.length}
/>
</div>
</div>
)}
{!loading && !error && data?.byWeek && data.byWeek.some(w => w.overtime > 0) && (
<div className="flex items-center gap-2 text-sm text-orange-600 dark:text-orange-400">
<AlertTriangle className="h-4 w-4" />
<span>
Overtime detected: {formatHours(data.totals.overtimeHours)} total
({data.byEmployee?.filter(e => e.overtimeHours > 0).length || 0} employees)
</span>
</div>
)}
</CardContent>
</Card>
);
};
function formatWeekRange(start: string, end: string): string {
const startDate = new Date(start + "T00:00:00");
const endDate = new Date(end + "T00:00:00");
const startStr = startDate.toLocaleDateString("en-US", { month: "short", day: "numeric" });
const endStr = endDate.toLocaleDateString("en-US", { month: "short", day: "numeric" });
return `${startStr} ${endStr}`;
}
type PayrollStatCardConfig = {
key: string;
title: string;
@@ -472,7 +618,7 @@ const ICON_MAP = {
hours: Clock,
overtime: AlertTriangle,
fte: Users,
avgHours: Clock,
periods: TrendingUp,
} as const;
function PayrollStatGrid({ cards }: { cards: PayrollStatCardConfig[] }) {
@@ -511,12 +657,21 @@ function SkeletonStats() {
);
}
const PayrollTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
type TrendChartPoint = {
label: string;
periodStart: string;
regular: number;
overtime: number;
total: number;
fte: number;
employees: number;
};
const PayrollTrendTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
if (!active || !payload?.length) return null;
const regular = payload.find(p => p.dataKey === "regular")?.value as number | undefined;
const overtime = payload.find(p => p.dataKey === "overtime")?.value as number | undefined;
const total = (regular || 0) + (overtime || 0);
const data = payload[0]?.payload as TrendChartPoint | undefined;
if (!data) return null;
return (
<div className={TOOLTIP_STYLES.container}>
@@ -524,35 +679,192 @@ const PayrollTooltip = ({ active, payload, label }: TooltipProps<number, string>
<div className={TOOLTIP_STYLES.content}>
<div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span
className={TOOLTIP_STYLES.dot}
style={{ backgroundColor: chartColors.regular }}
/>
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: chartColors.regular }} />
<span className={TOOLTIP_STYLES.name}>Regular Hours</span>
</div>
<span className={TOOLTIP_STYLES.value}>{formatHours(regular || 0)}</span>
<span className={TOOLTIP_STYLES.value}>{formatHours(data.regular)}</span>
</div>
{overtime != null && overtime > 0 && (
{data.overtime > 0 && (
<div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span
className={TOOLTIP_STYLES.dot}
style={{ backgroundColor: chartColors.overtime }}
/>
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: chartColors.overtime }} />
<span className={TOOLTIP_STYLES.name}>Overtime</span>
</div>
<span className={TOOLTIP_STYLES.value}>{formatHours(overtime)}</span>
<span className={TOOLTIP_STYLES.value}>{formatHours(data.overtime)}</span>
</div>
)}
<div className={`${TOOLTIP_STYLES.row} border-t border-border/50 pt-1 mt-1`}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.name}>Total</span>
<span className={TOOLTIP_STYLES.name}>Total Hours</span>
</div>
<span className={`${TOOLTIP_STYLES.value} font-semibold`}>{formatHours(total)}</span>
<span className={`${TOOLTIP_STYLES.value} font-semibold`}>{formatHours(data.total)}</span>
</div>
<div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: chartColors.fte }} />
<span className={TOOLTIP_STYLES.name}>FTE</span>
</div>
<span className={TOOLTIP_STYLES.value}>{formatNumber(data.fte, 2)}</span>
</div>
<div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.name}>Employees</span>
</div>
<span className={TOOLTIP_STYLES.value}>{data.employees}</span>
</div>
</div>
</div>
);
};
function EmployeeTableSkeleton() {
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} overflow-hidden`}>
<div className="p-3 border-b bg-muted/30">
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
</div>
<div className="p-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-2 py-1.5">
<div className="h-3 w-24 bg-muted rounded animate-pulse" />
<div className="flex-1" />
<div className="h-3 w-12 bg-muted rounded animate-pulse" />
<div className="h-3 w-12 bg-muted rounded animate-pulse" />
</div>
))}
</div>
</div>
);
}
type AggregatedEmployee = {
employeeId: number;
name: string;
totalHours: number;
overtimeHours: number;
regularHours: number;
periodCount: number;
week1Hours?: number;
week2Hours?: number;
week1Overtime?: number;
week2Overtime?: number;
};
function PayrollEmployeeSummary({
employees,
periodCount,
maxRows = 10
}: {
employees: AggregatedEmployee[];
periodCount: number;
maxRows?: number;
}) {
const sortedEmployees = useMemo(() => {
return [...employees]
.sort((a, b) => b.totalHours - a.totalHours)
.slice(0, maxRows);
}, [employees, maxRows]);
const hasWeekData = periodCount === 1 && employees.some((e) => e.week1Hours != null);
if (sortedEmployees.length === 0) {
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} flex items-center justify-center`}>
<p className="text-sm text-muted-foreground">No employee data</p>
</div>
);
}
const periodLabel = periodCount === 1
? "1 period"
: `${periodCount} periods`;
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} overflow-hidden flex flex-col`}>
<div className="p-3 border-b bg-muted/30 flex-none">
<h4 className="text-sm font-medium">
Employee Summary
<span className="text-muted-foreground font-normal ml-1">({periodLabel})</span>
</h4>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs px-3">Name</TableHead>
{hasWeekData ? (
<>
<TableHead className="h-8 text-xs px-2 text-right">Wk 1</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">Wk 2</TableHead>
</>
) : (
<TableHead className="h-8 text-xs px-2 text-right">Regular</TableHead>
)}
<TableHead className="h-8 text-xs px-2 text-right">Total</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">OT</TableHead>
{periodCount > 1 && (
<TableHead className="h-8 text-xs px-2 text-right">Avg/Per</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{sortedEmployees.map((emp) => (
<TableRow
key={emp.employeeId}
className={emp.overtimeHours > 0 ? "bg-orange-500/5" : ""}
>
<TableCell className="py-1.5 px-3 text-xs font-medium truncate max-w-[120px]">
{emp.name}
</TableCell>
{hasWeekData ? (
<>
<TableCell className="py-1.5 px-2 text-xs text-right">
<span className={emp.week1Overtime && emp.week1Overtime > 0 ? "text-orange-600 dark:text-orange-400 font-medium" : "text-muted-foreground"}>
{formatHours(emp.week1Hours || 0)}
</span>
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right">
<span className={emp.week2Overtime && emp.week2Overtime > 0 ? "text-orange-600 dark:text-orange-400 font-medium" : "text-muted-foreground"}>
{formatHours(emp.week2Hours || 0)}
</span>
</TableCell>
</>
) : (
<TableCell className="py-1.5 px-2 text-xs text-right text-muted-foreground">
{formatHours(emp.regularHours)}
</TableCell>
)}
<TableCell className="py-1.5 px-2 text-xs text-right font-medium">
{formatHours(emp.totalHours)}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right">
{emp.overtimeHours > 0 ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">
{formatHours(emp.overtimeHours)}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
{periodCount > 1 && (
<TableCell className="py-1.5 px-2 text-xs text-right text-muted-foreground">
{formatHours(emp.totalHours / emp.periodCount)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
{employees.length > maxRows && (
<div className="p-2 border-t text-center flex-none">
<span className="text-xs text-muted-foreground">
+{employees.length - maxRows} more employees
</span>
</div>
)}
</div>
);
}
export default PayrollMetrics;

View File

@@ -237,8 +237,7 @@ const OrdersDetails = ({ data }) => {
dataKey="orders"
name="Orders"
type="bar"
color="
"
color="hsl(221.2 83.2% 53.3%)"
/>
</div>
)}
@@ -376,7 +375,7 @@ const BrandsCategoriesDetails = ({ data }) => {
</TableHeader>
<TableBody className="max-h-[40vh] md:max-h-[60vh] overflow-auto">
{brandsList.map((brand) => (
<TableRow key={brand.name}>
<TableRow key={brand.id || brand.name}>
<TableCell className="font-medium">{brand.name}</TableCell>
<TableCell className="text-right">
{brand.count?.toLocaleString()}
@@ -407,7 +406,7 @@ const BrandsCategoriesDetails = ({ data }) => {
</TableHeader>
<TableBody className="max-h-[40vh] md:max-h-[60vh] overflow-auto">
{categoriesList.map((category) => (
<TableRow key={category.name}>
<TableRow key={category.id || category.name}>
<TableCell className="font-medium">{category.name}</TableCell>
<TableCell className="text-right">
{category.count?.toLocaleString()}
@@ -563,9 +562,9 @@ const OrderTypeDetails = ({ data, type }) => {
);
const timeSeriesData = data.map((day) => ({
timestamp: day.timestamp,
count: day.count,
value: day.value,
timestamp: day.timestamp || day.date,
count: day.count ?? day.orders, // Backend returns 'orders'
value: day.value ?? day.revenue, // Backend returns 'revenue'
percentage: day.percentage,
}));
@@ -623,10 +622,11 @@ const PeakHourDetails = ({ data }) => {
</div>
);
// hourlyOrders is now an array of {hour, count} objects in chronological order (rolling 24hrs)
const hourlyData =
data[0]?.hourlyOrders?.map((count, hour) => ({
timestamp: hour, // Use raw hour number for x-axis
orders: count,
data[0]?.hourlyOrders?.map((item) => ({
timestamp: item.hour, // The actual hour (0-23)
orders: item.count,
})) || [];
return (
@@ -996,13 +996,11 @@ const StatCards = ({
const [lastUpdate, setLastUpdate] = useState(null);
const [timeRange, setTimeRange] = useState(initialTimeRange);
const [selectedMetric, setSelectedMetric] = useState(null);
const [dateRange, setDateRange] = useState(null);
const [detailDataLoading, setDetailDataLoading] = useState({});
const [detailData, setDetailData] = useState({});
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [projection, setProjection] = useState(null);
const [projectionLoading, setProjectionLoading] = useState(false);
const { setCacheData, getCacheData, clearCache } = useDataCache();
const { setCacheData, getCacheData } = useDataCache();
// Function to determine if we should use last30days for trend charts
const shouldUseLast30Days = useCallback(
@@ -1218,8 +1216,14 @@ const StatCards = ({
const calculateOrderTrend = useCallback(() => {
if (!stats?.prevPeriodOrders) return null;
return calculateTrend(stats.orderCount, stats.prevPeriodOrders);
}, [stats, calculateTrend]);
// If period is incomplete, use projected orders for fair comparison
const currentOrders = stats.periodProgress < 100
? (projection?.projectedOrders || Math.round(stats.orderCount / (stats.periodProgress / 100)))
: stats.orderCount;
return calculateTrend(currentOrders, stats.prevPeriodOrders);
}, [stats, projection, calculateTrend]);
const calculateAOVTrend = useCallback(() => {
if (!stats?.prevPeriodAOV) return null;
@@ -1242,7 +1246,6 @@ const StatCards = ({
if (!isMounted) return;
setDateRange(response.timeRange);
setStats(response.stats);
setLastUpdate(DateTime.now().setZone("America/New_York"));
setError(null);
@@ -1257,7 +1260,6 @@ const StatCards = ({
} finally {
if (isMounted) {
setLoading(false);
setIsInitialLoad(false);
}
}
};
@@ -1321,69 +1323,30 @@ const StatCards = ({
return () => clearInterval(interval);
}, [timeRange]);
// Modified AsyncDetailView component
const AsyncDetailView = memo(({ metric, type, orderCount }) => {
const detailTimeRange = shouldUseLast30Days(metric)
// Fetch detail data when a metric is selected (if not already cached)
useEffect(() => {
if (!selectedMetric) return;
// Skip metrics that use stats directly instead of fetched detail data
if (["brands_categories", "shipping", "peak_hour"].includes(selectedMetric)) {
return;
}
const detailTimeRange = shouldUseLast30Days(selectedMetric)
? "last30days"
: timeRange;
const cachedData =
detailData[metric] || getCacheData(detailTimeRange, metric);
const isLoading = detailDataLoading[metric];
const isOrderTypeMetric = [
"pre_orders",
"local_pickup",
"on_hold",
].includes(metric);
const cachedData = detailData[selectedMetric] || getCacheData(detailTimeRange, selectedMetric);
const isLoading = detailDataLoading[selectedMetric];
useEffect(() => {
let isMounted = true;
const loadData = async () => {
if (!cachedData && !isLoading) {
// Pass type only for order type metrics
const data = await fetchDetailData(
metric,
isOrderTypeMetric ? metric : undefined
);
if (!isMounted) return;
// The state updates are handled in fetchDetailData
const isOrderTypeMetric = ["pre_orders", "local_pickup", "on_hold"].includes(selectedMetric);
fetchDetailData(selectedMetric, isOrderTypeMetric ? selectedMetric : undefined);
}
};
}, [selectedMetric, timeRange, shouldUseLast30Days, detailData, detailDataLoading, getCacheData, fetchDetailData]);
loadData();
return () => {
isMounted = false;
};
}, [metric, timeRange, isOrderTypeMetric]); // Depend on isOrderTypeMetric
if (isLoading || (!cachedData && !error)) {
switch (metric) {
case "revenue":
case "orders":
case "average_order":
return <ChartSkeleton type="line" height="default" withCard={false} />;
case "refunds":
case "cancellations":
case "order_range":
case "pre_orders":
case "local_pickup":
case "on_hold":
return <ChartSkeleton type="bar" height="default" withCard={false} />;
case "brands_categories":
case "shipping":
return <TableSkeleton rows={8} columns={3} />;
case "peak_hour":
return <ChartSkeleton type="bar" height="default" withCard={false} />;
default:
return <div className="text-muted-foreground">Loading...</div>;
}
}
if (!cachedData && error) {
return <DashboardErrorState error={`Failed to load stats: ${error}`} />;
}
if (!cachedData) {
// Modified getDetailComponent to use memoized components
const getDetailComponent = useCallback(() => {
if (!selectedMetric || !stats) {
return (
<DashboardEmptyState
title="No data available"
@@ -1393,57 +1356,22 @@ const StatCards = ({
);
}
switch (metric) {
case "revenue":
return <MemoizedRevenueDetails data={cachedData} />;
case "orders":
return <MemoizedOrdersDetails data={cachedData} />;
case "average_order":
return (
<MemoizedAverageOrderDetails
data={cachedData}
orderCount={orderCount}
/>
);
case "refunds":
return <MemoizedRefundDetails data={cachedData} />;
case "cancellations":
return <MemoizedCancellationsDetails data={cachedData} />;
case "order_range":
return <MemoizedOrderRangeDetails data={cachedData} />;
case "pre_orders":
case "local_pickup":
case "on_hold":
return <MemoizedOrderTypeDetails data={cachedData} type={type} />;
default:
return (
<div className="text-muted-foreground">Invalid metric selected.</div>
);
}
});
AsyncDetailView.displayName = "AsyncDetailView";
// Modified getDetailComponent to use memoized components
const getDetailComponent = useCallback(() => {
if (!selectedMetric || !stats) {
return (
<div className="text-muted-foreground">
No data available for the selected time range.
</div>
);
}
const data = detailData[selectedMetric];
const isLoading = detailDataLoading[selectedMetric];
const isOrderTypeMetric = [
"pre_orders",
"local_pickup",
"on_hold",
].includes(selectedMetric);
if (isLoading) {
return <ChartSkeleton height="default" withCard={false} />;
// Show metric-specific loading skeletons
switch (selectedMetric) {
case "brands_categories":
case "shipping":
return <TableSkeleton rows={8} columns={3} />;
case "revenue":
case "orders":
case "average_order":
return <ChartSkeleton type="line" height="default" withCard={false} />;
default:
return <ChartSkeleton type="bar" height="default" withCard={false} />;
}
}
switch (selectedMetric) {
@@ -1659,7 +1587,7 @@ const StatCards = ({
projectionLoading && stats?.periodProgress < 100
? undefined
: revenueTrend?.value
? { value: revenueTrend.value, moreIsBetter: revenueTrend.trend === "up" }
? { value: revenueTrend.trend === "up" ? revenueTrend.value : -revenueTrend.value, moreIsBetter: true }
: undefined
}
icon={DollarSign}
@@ -1672,7 +1600,13 @@ const StatCards = ({
title="Orders"
value={stats?.orderCount}
subtitle={`${stats?.itemCount} total items`}
trend={orderTrend?.value ? { value: orderTrend.value, moreIsBetter: orderTrend.trend === "up" } : undefined}
trend={
projectionLoading && stats?.periodProgress < 100
? undefined
: orderTrend?.value
? { value: orderTrend.trend === "up" ? orderTrend.value : -orderTrend.value, moreIsBetter: true }
: undefined
}
icon={ShoppingCart}
iconColor="blue"
onClick={() => setSelectedMetric("orders")}
@@ -1684,7 +1618,7 @@ const StatCards = ({
value={stats?.averageOrderValue?.toFixed(2)}
valuePrefix="$"
subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
trend={aovTrend?.value ? { value: aovTrend.value, moreIsBetter: aovTrend.trend === "up" } : undefined}
trend={aovTrend?.value ? { value: aovTrend.trend === "up" ? aovTrend.value : -aovTrend.value, moreIsBetter: true } : undefined}
icon={CircleDollarSign}
iconColor="purple"
onClick={() => setSelectedMetric("average_order")}
@@ -1714,7 +1648,9 @@ const StatCards = ({
<DashboardStatCard
title="Pre-Orders"
value={
((stats?.orderTypes?.preOrders?.count / stats?.orderCount) * 100)?.toFixed(1) || "0"
stats?.orderCount > 0
? ((stats?.orderTypes?.preOrders?.count / stats?.orderCount) * 100).toFixed(1)
: "0"
}
valueSuffix="%"
subtitle={`${stats?.orderTypes?.preOrders?.count || 0} orders`}
@@ -1727,7 +1663,9 @@ const StatCards = ({
<DashboardStatCard
title="Local Pickup"
value={
((stats?.orderTypes?.localPickup?.count / stats?.orderCount) * 100)?.toFixed(1) || "0"
stats?.orderCount > 0
? ((stats?.orderTypes?.localPickup?.count / stats?.orderCount) * 100).toFixed(1)
: "0"
}
valueSuffix="%"
subtitle={`${stats?.orderTypes?.localPickup?.count || 0} orders`}
@@ -1740,7 +1678,9 @@ const StatCards = ({
<DashboardStatCard
title="On Hold"
value={
((stats?.orderTypes?.heldItems?.count / stats?.orderCount) * 100)?.toFixed(1) || "0"
stats?.orderCount > 0
? ((stats?.orderTypes?.heldItems?.count / stats?.orderCount) * 100).toFixed(1)
: "0"
}
valueSuffix="%"
subtitle={`${stats?.orderTypes?.heldItems?.count || 0} orders`}

View File

@@ -106,7 +106,7 @@ export const DashboardSectionHeader: React.FC<DashboardSectionHeaderProps> = ({
compact = false,
size = "default",
}) => {
const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-4";
const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-2";
const titleClass = size === "large"
? "text-xl font-semibold text-foreground"
: "text-lg font-semibold text-foreground";

View File

@@ -10,12 +10,18 @@
* value="$12,345"
* gradient="emerald"
* icon={DollarSign}
* trend={{ value: 12.5, label: "vs last month" }}
* />
*/
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown, type LucideIcon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ArrowUp, ArrowDown, Minus, Info, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
@@ -35,6 +41,17 @@ export type GradientVariant =
| "sky"
| "custom";
export interface TrendProps {
/** The percentage or absolute change value */
value: number;
/** Optional label to show after the trend (e.g., "vs last month") */
label?: string;
/** Whether a higher value is better (affects color). Defaults to true. */
moreIsBetter?: boolean;
/** Suffix for the trend value (defaults to "%"). Use "" for no suffix. */
suffix?: string;
}
export interface DashboardStatCardMiniProps {
/** Card title/label */
title: string;
@@ -44,13 +61,10 @@ export interface DashboardStatCardMiniProps {
valuePrefix?: string;
/** Optional suffix for the value (e.g., "%") */
valueSuffix?: string;
/** Optional description text or element */
description?: React.ReactNode;
/** Trend direction and value */
trend?: {
direction: "up" | "down";
value: string;
};
/** Optional subtitle or description (can be string or JSX) */
subtitle?: React.ReactNode;
/** Optional trend indicator */
trend?: TrendProps;
/** Optional icon component */
icon?: LucideIcon;
/** Icon background color class (e.g., "bg-emerald-500/20") */
@@ -61,6 +75,12 @@ export interface DashboardStatCardMiniProps {
className?: string;
/** Click handler */
onClick?: () => void;
/** Loading state */
loading?: boolean;
/** Tooltip text shown via info icon next to title */
tooltip?: string;
/** Additional content to render below the main value */
children?: React.ReactNode;
}
// =============================================================================
@@ -81,6 +101,53 @@ const GRADIENT_PRESETS: Record<GradientVariant, string> = {
custom: "",
};
// =============================================================================
// HELPER COMPONENTS
// =============================================================================
/**
* Get trend colors optimized for dark gradient backgrounds
*/
const getTrendColors = (value: number, moreIsBetter: boolean = true): string => {
const isPositive = value > 0;
const isGood = moreIsBetter ? isPositive : !isPositive;
if (value === 0) {
return "text-gray-400";
}
return isGood ? "text-emerald-400" : "text-rose-400";
};
interface TrendIndicatorProps {
value: number;
label?: string;
moreIsBetter?: boolean;
suffix?: string;
}
const TrendIndicator: React.FC<TrendIndicatorProps> = ({
value,
label,
moreIsBetter = true,
suffix = "%",
}) => {
const colorClass = getTrendColors(value, moreIsBetter);
const IconComponent = value > 0 ? ArrowUp : value < 0 ? ArrowDown : Minus;
// Format the value - round to integer for compact display (preserves sign for negatives)
const formattedValue = Math.round(value);
return (
<span className={cn("flex items-center gap-0.5 text-sm font-semibold", colorClass)}>
<IconComponent className="h-4 w-4" />
{value > 0 ? "+" : ""}
{formattedValue}
{suffix}
{label && <span className="text-gray-300 font-normal ml-1">{label}</span>}
</span>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
@@ -90,16 +157,41 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
value,
valuePrefix,
valueSuffix,
description,
subtitle,
trend,
icon: Icon,
iconBackground,
gradient = "slate",
className,
onClick,
loading = false,
tooltip,
children,
}) => {
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
// Loading state
if (loading) {
return (
<Card
className={cn(
gradientClass,
"backdrop-blur-md border-white/10",
className
)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-3">
<div className="h-4 w-20 bg-white/20 animate-pulse rounded" />
{Icon && <div className="h-9 w-9 bg-white/20 animate-pulse rounded-full" />}
</CardHeader>
<CardContent className="p-4 pt-1">
<div className="h-9 w-28 bg-white/20 animate-pulse rounded mb-2" />
{subtitle && <div className="h-4 w-24 bg-white/10 animate-pulse rounded" />}
</CardContent>
</Card>
);
}
return (
<Card
className={cn(
@@ -110,10 +202,28 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
)}
onClick={onClick}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className="text-sm font-bold text-gray-100">
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-3">
<div className="flex items-center gap-1.5">
<CardTitle className="text-xs font-medium text-gray-100 uppercase tracking-wide">
{title}
</CardTitle>
{tooltip && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-gray-400 hover:text-gray-200 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<Info className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-sm">{tooltip}</p>
</TooltipContent>
</Tooltip>
)}
</div>
{Icon && (
<div className="relative p-2">
{iconBackground && (
@@ -121,11 +231,11 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
className={cn("absolute inset-0 rounded-full", iconBackground)}
/>
)}
<Icon className="h-5 w-5 text-white relative" />
<Icon className="h-4 w-4 text-white relative" />
</div>
)}
</CardHeader>
<CardContent className="p-4 pt-0">
<CardContent className="p-4 pt-1">
<div className="text-3xl font-extrabold text-white">
{valuePrefix}
{typeof value === "number" ? value.toLocaleString() : value}
@@ -133,32 +243,24 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
<span className="text-xl text-gray-300">{valueSuffix}</span>
)}
</div>
{(description || trend) && (
<div className="flex items-center gap-2 mt-1">
{trend && (
<span
className={cn(
"flex items-center gap-1 text-sm font-semibold",
trend.direction === "up"
? "text-emerald-300"
: "text-rose-300"
)}
>
{trend.direction === "up" ? (
<TrendingUp className="h-4 w-4" />
) : (
<TrendingDown className="h-4 w-4" />
)}
{trend.value}
</span>
)}
{description && (
{(subtitle || trend) && (
<div className="flex flex-wrap items-center justify-between gap-2 mt-3">
{subtitle && (
<span className="text-sm font-semibold text-gray-200">
{description}
{subtitle}
</span>
)}
{trend && (
<TrendIndicator
value={trend.value}
label={trend.label}
moreIsBetter={trend.moreIsBetter}
suffix={trend.suffix}
/>
)}
</div>
)}
{children}
</CardContent>
</Card>
);
@@ -170,12 +272,14 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
export interface DashboardStatCardMiniSkeletonProps {
gradient?: GradientVariant;
hasIcon?: boolean;
hasSubtitle?: boolean;
className?: string;
}
export const DashboardStatCardMiniSkeleton: React.FC<
DashboardStatCardMiniSkeletonProps
> = ({ gradient = "slate", className }) => {
> = ({ gradient = "slate", hasIcon = true, hasSubtitle = true, className }) => {
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
return (
@@ -186,13 +290,13 @@ export const DashboardStatCardMiniSkeleton: React.FC<
className
)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-3">
<div className="h-4 w-20 bg-white/20 animate-pulse rounded" />
<div className="h-9 w-9 bg-white/20 animate-pulse rounded-full" />
{hasIcon && <div className="h-9 w-9 bg-white/20 animate-pulse rounded-full" />}
</CardHeader>
<CardContent className="p-4 pt-0">
<CardContent className="p-4 pt-1">
<div className="h-9 w-28 bg-white/20 animate-pulse rounded mb-2" />
<div className="h-4 w-24 bg-white/10 animate-pulse rounded" />
{hasSubtitle && <div className="h-4 w-24 bg-white/10 animate-pulse rounded" />}
</CardContent>
</Card>
);

View File

@@ -14,6 +14,7 @@ import {
FileSearch,
ShoppingCart,
FilePenLine,
Mail,
} from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react";
import {
@@ -120,6 +121,12 @@ const toolsItems = [
icon: FilePenLine,
url: "/product-editor",
permission: "access:product_editor"
},
{
title: "Newsletter",
icon: Mail,
url: "/newsletter",
permission: "access:newsletter"
}
];

View File

@@ -0,0 +1,597 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { History, ChevronDown, ChevronRight, ChevronLeft, ExternalLink } from "lucide-react"
import config from "@/config"
function useCampaignData(open: boolean) {
const campaigns = useQuery<CampaignsResponse>({
queryKey: ["newsletter-campaigns"],
queryFn: async () => {
const res = await fetch(`${config.apiUrl}/newsletter/campaigns`)
if (!res.ok) throw new Error("Failed to fetch campaigns")
return res.json()
},
enabled: open,
staleTime: 5 * 60_000,
})
const products = useQuery<{ products: ProductAggregate[] }>({
queryKey: ["newsletter-campaigns-products"],
queryFn: async () => {
const res = await fetch(`${config.apiUrl}/newsletter/campaigns/products`)
if (!res.ok) throw new Error("Failed to fetch")
return res.json()
},
enabled: open,
staleTime: 5 * 60_000,
})
const links = useQuery<{ links: LinkAggregate[] }>({
queryKey: ["newsletter-campaigns-links"],
queryFn: async () => {
const res = await fetch(`${config.apiUrl}/newsletter/campaigns/links`)
if (!res.ok) throw new Error("Failed to fetch")
return res.json()
},
enabled: open,
staleTime: 5 * 60_000,
})
const brands = useQuery<{ brands: BrandAggregate[] }>({
queryKey: ["newsletter-campaigns-brands"],
queryFn: async () => {
const res = await fetch(`${config.apiUrl}/newsletter/campaigns/brands`)
if (!res.ok) throw new Error("Failed to fetch")
return res.json()
},
enabled: open,
staleTime: 5 * 60_000,
})
return { campaigns, products, brands, links }
}
// ── Types ────────────────────────────────────────────
interface CampaignProduct {
pid: number
title: string
sku: string
brand: string | null
line: string | null
image: string | null
product_url: string | null
}
interface CampaignLink {
link_url: string
link_type: string
}
interface Campaign {
campaign_id: string
campaign_name: string
sent_at: string
product_count: number
products: CampaignProduct[]
links: CampaignLink[]
}
interface CampaignSummary {
total_campaigns: number
total_unique_products: number
avg_products_per_campaign: number
}
interface CampaignsResponse {
campaigns: Campaign[]
summary: CampaignSummary
}
interface ProductAggregate {
pid: number
title: string
sku: string
brand: string
image: string | null
permalink: string | null
times_featured: number
first_featured_at: string
last_featured_at: string
days_since_featured: number
featured_span_days: number
avg_days_between_features: number | null
campaigns: { campaign_id: string; campaign_name: string; sent_at: string }[]
}
interface BrandAggregate {
brand: string
product_count: number
times_featured: number
first_featured_at: string
last_featured_at: string
days_since_featured: number
avg_days_between_features: number | null
campaigns: { campaign_id: string; campaign_name: string; sent_at: string }[]
}
interface LinkAggregate {
link_url: string
link_type: string
times_used: number
first_used_at: string
last_used_at: string
days_since_used: number
campaign_names: string[]
}
// ── Campaign Row (expandable) ────────────────────────
function CampaignRow({ campaign }: { campaign: Campaign }) {
const [expanded, setExpanded] = useState(false)
return (
<>
<TableRow
className="cursor-pointer hover:bg-muted/50"
onClick={() => setExpanded(!expanded)}
>
<TableCell className="w-[30px]">
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</TableCell>
<TableCell className="font-medium text-sm">{campaign.campaign_name || campaign.campaign_id}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{campaign.sent_at ? new Date(campaign.sent_at).toLocaleDateString() : "—"}
</TableCell>
<TableCell className="text-right text-sm">{campaign.product_count}</TableCell>
<TableCell className="text-right text-sm">{campaign.links.length}</TableCell>
</TableRow>
{expanded && (
<TableRow>
<TableCell colSpan={5} className="p-0">
<div className="bg-muted/30 p-3 space-y-3">
<div>
<p className="text-xs font-semibold mb-1.5">Products ({campaign.products.length})</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5" style={{ gridAutoFlow: "column", gridTemplateRows: `repeat(${Math.ceil(campaign.products.length / 2)}, minmax(0, auto))` }}>
{campaign.products.map((p) => (
<div key={p.pid} className="flex items-center gap-2 text-xs bg-background rounded px-2 py-1">
{p.image ? (
<img src={p.image} alt="" className="w-6 h-6 object-cover rounded shrink-0" />
) : (
<div className="w-6 h-6 bg-muted rounded shrink-0" />
)}
<span className="truncate flex-1">{p.title}</span>
<span className="text-muted-foreground shrink-0">{p.pid}</span>
{p.product_url && (
<a href={p.product_url} target="_blank" rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground shrink-0"
onClick={(e) => e.stopPropagation()}>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
))}
</div>
</div>
{campaign.links.length > 0 && (
<div>
<p className="text-xs font-semibold mb-1.5">Links ({campaign.links.length})</p>
<div className="space-y-1">
{campaign.links.map((l, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{l.link_type || "other"}</Badge>
<a href={l.link_url} target="_blank" rel="noopener noreferrer"
className="text-blue-500 hover:underline truncate"
onClick={(e) => e.stopPropagation()}>
{l.link_url}
</a>
</div>
))}
</div>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
)
}
// ── Product Row (expandable campaign list) ───────────
function ProductRow({ product }: { product: ProductAggregate }) {
const [expanded, setExpanded] = useState(false)
return (
<>
<TableRow
className="cursor-pointer hover:bg-muted/50"
onClick={() => setExpanded(!expanded)}
>
<TableCell className="w-[30px]">
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</TableCell>
<TableCell>
<div className="flex items-center gap-2 max-w-[400px]">
{product.image ? (
<img src={product.image} alt="" className="w-7 h-7 object-cover rounded shrink-0" />
) : (
<div className="w-7 h-7 bg-muted rounded shrink-0" />
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">{product.title}</p>
<p className="text-xs text-muted-foreground">{product.sku}</p>
</div>
</div>
</TableCell>
<TableCell className="text-sm">{product.brand}</TableCell>
<TableCell className="text-right text-sm font-medium">{product.times_featured}×</TableCell>
<TableCell className="text-sm text-muted-foreground">
{product.first_featured_at ? new Date(product.first_featured_at).toLocaleDateString() : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{product.days_since_featured === 0 ? "Today" : `${product.days_since_featured}d ago`}
</TableCell>
<TableCell className="text-sm text-muted-foreground text-right">
{product.avg_days_between_features != null ? `${product.avg_days_between_features}d` : "—"}
</TableCell>
<TableCell className="w-[30px]">
{product.permalink && (
<a href={product.permalink} target="_blank" rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}>
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</TableCell>
</TableRow>
{expanded && (
<TableRow>
<TableCell colSpan={8} className="p-0">
<div className="bg-muted/30 p-3">
<p className="text-xs font-semibold mb-1.5">Campaigns ({product.campaigns.length})</p>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1">
{product.campaigns.map((c) => (
<div key={c.campaign_id} className="flex items-center gap-2 text-xs bg-background rounded px-2 py-1">
<span className="text-muted-foreground shrink-0">
{c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"}
</span>
<span className="truncate">{c.campaign_name || c.campaign_id}</span>
</div>
))}
</div>
</div>
</TableCell>
</TableRow>
)}
</>
)
}
// ── Skeleton loader ──────────────────────────────────
function TableSkeleton({ rows = 8 }: { rows?: number }) {
return (
<div className="p-4 space-y-3">
{Array.from({ length: rows }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}
</div>
)
}
// ── Tab: Campaigns ───────────────────────────────────
function CampaignsTab({ data, isLoading }: { data: CampaignsResponse | undefined; isLoading: boolean }) {
return (
<div className="space-y-4">
{isLoading ? (
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}><CardContent className="p-3"><Skeleton className="h-3 w-24 mb-1" /><Skeleton className="h-7 w-12" /></CardContent></Card>
))}
</div>
) : data?.summary ? (
<div className="grid grid-cols-3 gap-4">
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Total Campaigns</p>
<p className="text-xl font-bold">{Number(data.summary.total_campaigns).toLocaleString()}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Unique Products Featured</p>
<p className="text-xl font-bold">{Number(data.summary.total_unique_products).toLocaleString()}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Avg Products / Campaign</p>
<p className="text-xl font-bold">{data.summary.avg_products_per_campaign}</p>
</CardContent>
</Card>
</div>
) : null}
<div className="flex-1 overflow-auto rounded-md border max-h-[50vh]">
{isLoading ? <TableSkeleton /> : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[30px]"></TableHead>
<TableHead>Campaign</TableHead>
<TableHead>Sent</TableHead>
<TableHead className="text-right">Products</TableHead>
<TableHead className="text-right">Links</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.campaigns.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">No campaigns found</TableCell>
</TableRow>
) : (
data?.campaigns.map((c) => <CampaignRow key={c.campaign_id} campaign={c} />)
)}
</TableBody>
</Table>
)}
</div>
</div>
)
}
// ── Tab: Products ────────────────────────────────────
const PRODUCTS_PAGE_SIZE = 500
function ProductsTab({ data, isLoading }: { data: { products: ProductAggregate[] } | undefined; isLoading: boolean }) {
const [page, setPage] = useState(1)
const allProducts = data?.products ?? []
const totalPages = Math.ceil(allProducts.length / PRODUCTS_PAGE_SIZE)
const pageProducts = useMemo(
() => allProducts.slice((page - 1) * PRODUCTS_PAGE_SIZE, page * PRODUCTS_PAGE_SIZE),
[allProducts, page]
)
return (
<div className="space-y-2">
<div className="flex-1 overflow-auto rounded-md border max-h-[55vh]">
{isLoading ? <TableSkeleton /> : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[30px]"></TableHead>
<TableHead>Product</TableHead>
<TableHead>Brand</TableHead>
<TableHead className="text-right">Featured</TableHead>
<TableHead>First</TableHead>
<TableHead>Last</TableHead>
<TableHead className="text-right">Avg Gap</TableHead>
<TableHead className="w-[30px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageProducts.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">No products found</TableCell>
</TableRow>
) : (
pageProducts.map((p) => <ProductRow key={p.pid} product={p} />)
)}
</TableBody>
</Table>
)}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{allProducts.length.toLocaleString()} products
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm">Page {page} of {totalPages}</span>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}
// ── Brand Row (expandable campaign list) ─────────────
function BrandRow({ brand }: { brand: BrandAggregate }) {
const [expanded, setExpanded] = useState(false)
return (
<>
<TableRow
className="cursor-pointer hover:bg-muted/50"
onClick={() => setExpanded(!expanded)}
>
<TableCell className="w-[30px]">
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</TableCell>
<TableCell className="text-sm font-medium">{brand.brand}</TableCell>
<TableCell className="text-right text-sm">{brand.product_count}</TableCell>
<TableCell className="text-right text-sm font-medium">{brand.times_featured}×</TableCell>
<TableCell className="text-sm text-muted-foreground">
{brand.first_featured_at ? new Date(brand.first_featured_at).toLocaleDateString() : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{brand.days_since_featured === 0 ? "Today" : `${brand.days_since_featured}d ago`}
</TableCell>
<TableCell className="text-sm text-muted-foreground text-right">
{brand.avg_days_between_features != null ? `${brand.avg_days_between_features}d` : "—"}
</TableCell>
</TableRow>
{expanded && (
<TableRow>
<TableCell colSpan={7} className="p-0">
<div className="bg-muted/30 p-3">
<p className="text-xs font-semibold mb-1.5">Campaigns ({brand.campaigns.length})</p>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1">
{brand.campaigns.map((c) => (
<div key={c.campaign_id} className="flex items-center gap-2 text-xs bg-background rounded px-2 py-1">
<span className="text-muted-foreground shrink-0">
{c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"}
</span>
<span className="truncate">{c.campaign_name || c.campaign_id}</span>
</div>
))}
</div>
</div>
</TableCell>
</TableRow>
)}
</>
)
}
// ── Tab: Brands ──────────────────────────────────────
function BrandsTab({ data, isLoading }: { data: { brands: BrandAggregate[] } | undefined; isLoading: boolean }) {
return (
<div className="flex-1 overflow-auto rounded-md border max-h-[55vh]">
{isLoading ? <TableSkeleton /> : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[30px]"></TableHead>
<TableHead>Brand</TableHead>
<TableHead className="text-right">Products</TableHead>
<TableHead className="text-right">Featured</TableHead>
<TableHead>First</TableHead>
<TableHead>Last</TableHead>
<TableHead className="text-right">Avg Gap</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.brands.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No brands found</TableCell>
</TableRow>
) : (
data?.brands.map((b) => <BrandRow key={b.brand} brand={b} />)
)}
</TableBody>
</Table>
)}
</div>
)
}
// ── Tab: Links ───────────────────────────────────────
function LinksTab({ data, isLoading }: { data: { links: LinkAggregate[] } | undefined; isLoading: boolean }) {
return (
<div className="flex-1 overflow-auto rounded-md border max-h-[60vh]">
{isLoading ? <TableSkeleton /> : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Link</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Used</TableHead>
<TableHead>First</TableHead>
<TableHead>Last</TableHead>
<TableHead className="text-right">Campaigns</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.links.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No links found</TableCell>
</TableRow>
) : (
data?.links.map((l, i) => (
<TableRow key={i}>
<TableCell className="max-w-[500px]">
<a href={l.link_url} target="_blank" rel="noopener noreferrer"
className="text-sm text-blue-500 hover:underline truncate block">
{l.link_url}
</a>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{l.link_type || "other"}</Badge>
</TableCell>
<TableCell className="text-right text-sm font-medium">{l.times_used}×</TableCell>
<TableCell className="text-sm text-muted-foreground">
{l.first_used_at ? new Date(l.first_used_at).toLocaleDateString() : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{l.days_since_used === 0 ? "Today" : `${l.days_since_used}d ago`}
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground">
{l.campaign_names?.length ?? 0}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
)
}
// ── Main Dialog ──────────────────────────────────────
export function CampaignHistoryDialog() {
const [open, setOpen] = useState(false)
const { campaigns, products, brands, links } = useCampaignData(open)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<History className="h-4 w-4 mr-2" />
Campaign History
</Button>
</DialogTrigger>
<DialogContent className="max-w-5xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Newsletter Campaign History</DialogTitle>
</DialogHeader>
<Tabs defaultValue="campaigns" className="">
<TabsList>
<TabsTrigger value="campaigns">Campaigns</TabsTrigger>
<TabsTrigger value="products">Products</TabsTrigger>
<TabsTrigger value="brands">Brands</TabsTrigger>
<TabsTrigger value="links">Links</TabsTrigger>
</TabsList>
<TabsContent value="campaigns" forceMount className="flex-1 min-h-0 data-[state=inactive]:hidden">
<CampaignsTab data={campaigns.data} isLoading={campaigns.isLoading} />
</TabsContent>
<TabsContent value="products" forceMount className="flex-1 min-h-0 data-[state=inactive]:hidden">
<ProductsTab data={products.data} isLoading={products.isLoading} />
</TabsContent>
<TabsContent value="brands" forceMount className="flex-1 min-h-0 data-[state=inactive]:hidden">
<BrandsTab data={brands.data} isLoading={brands.isLoading} />
</TabsContent>
<TabsContent value="links" forceMount className="flex-1 min-h-0 data-[state=inactive]:hidden">
<LinksTab data={links.data} isLoading={links.isLoading} />
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,110 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent } from "@/components/ui/card"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Skeleton } from "@/components/ui/skeleton"
import { Sparkles, RotateCcw, TrendingUp, Clock, CalendarClock, EyeOff, Info } from "lucide-react"
import config from "@/config"
interface Stats {
unfeatured_new: number
back_in_stock_ready: number
high_score_available: number
last_campaign_date: string
avg_days_since_featured: number
never_featured: number
}
export function NewsletterStats() {
const { data } = useQuery<Stats>({
queryKey: ["newsletter-stats"],
queryFn: async () => {
const res = await fetch(`${config.apiUrl}/newsletter/stats`)
if (!res.ok) throw new Error("Failed to fetch stats")
return res.json()
},
})
if (!data) {
return (
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 rounded" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-8 w-16 mt-1" />
</CardContent>
</Card>
))}
</div>
)
}
const stats = [
{
label: "Unfeatured New",
value: data.unfeatured_new?.toLocaleString() ?? "—",
icon: Sparkles,
tooltip: "New products (< 31 days) that haven't appeared in any newsletter yet. If this is high, prioritize new arrivals today.",
},
{
label: "Back in Stock Ready",
value: data.back_in_stock_ready?.toLocaleString() ?? "—",
icon: RotateCcw,
tooltip: "Restocked products that haven't been featured since their restock date. These are time-sensitive — customers are waiting and stock could sell through.",
},
{
label: "High Score Available",
value: data.high_score_available?.toLocaleString() ?? "—",
icon: TrendingUp,
tooltip: "Products scoring 40+ that haven't been featured in the last 2 days. Shows how deep your bench of strong picks is for today's send.",
},
{
label: "Last Campaign",
value: data.last_campaign_date ? new Date(data.last_campaign_date).toLocaleDateString() : "—",
icon: Clock,
tooltip: "Date of the most recent synced campaign. Useful to confirm your Klaviyo sync is up to date.",
},
{
label: "Avg Days Since Featured",
value: data.avg_days_since_featured ?? "—",
icon: CalendarClock,
tooltip: "Average days since in-stock products were last featured. If this is climbing, you're not cycling through enough of your catalog.",
},
{
label: "Never Featured",
value: data.never_featured?.toLocaleString() ?? "—",
icon: EyeOff,
tooltip: "Visible, in-stock products that have never appeared in any newsletter. Your untapped opportunity pool.",
},
]
return (
<TooltipProvider>
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{stats.map((s) => (
<Card key={s.label}>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs font-medium">
<s.icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{s.label}</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 shrink-0 cursor-help opacity-50 hover:opacity-100 transition-opacity" />
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[240px]">
<p className="text-xs font-medium">{s.label}</p>
<p>{s.tooltip}</p>
</TooltipContent>
</Tooltip>
</div>
<p className="text-2xl font-bold mt-1">{s.value}</p>
</CardContent>
</Card>
))}
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,471 @@
import { useQuery } from "@tanstack/react-query"
import { useState, useMemo, useContext } from "react"
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
} from "@/components/ui/tooltip"
import { ChevronLeft, ChevronRight, ExternalLink, Layers, Copy, Check, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"
import { toast } from "sonner"
import { AuthContext } from "@/contexts/AuthContext"
import config from "@/config"
interface Product {
pid: number
title: string
sku: string
brand: string
vendor: string
price: number
regular_price: number
image: string
permalink: string
stock_quantity: number
preorder_count: number
sales_7d: number
sales_30d: number
revenue_30d: number
current_stock: number
on_order_qty: number
abc_class: string
line: string | null
times_featured: number | null
last_featured_at: string | null
days_since_featured: number | null
line_products_featured: number | null
line_total_features: number | null
line_last_featured_at: string | null
line_products_featured_30d: number | null
line_product_count: number | null
line_days_since_featured: number | null
effective_last_featured: string | null
effective_days_since_featured: number | null
age_days: number
score: number
is_new: boolean
is_preorder: boolean
is_clearance: boolean
discount_pct: number
is_low_stock: boolean
is_back_in_stock: boolean
is_daily_deal: boolean
deal_price: number | null
baskets: number
notifies: number
}
interface RecommendationResponse {
products: Product[]
pagination: {
total: number
pages: number
currentPage: number
limit: number
}
}
interface RecommendationTableProps {
category: string
}
function FeaturedCell({ p }: { p: Product }) {
const directCount = p.times_featured ?? 0
const hasLineHistory = p.line && p.line_last_featured_at && !p.last_featured_at
return (
<div className="flex items-center justify-center gap-1.5">
<span className="text-sm">{directCount}×</span>
{hasLineHistory && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Layers className="h-3.5 w-3.5 text-blue-500" />
</TooltipTrigger>
<TooltipContent side="left" className="max-w-[220px]">
<p className="text-xs font-medium">Line: {p.line}</p>
<p className="text-xs text-muted-foreground">
{p.line_products_featured} of {p.line_product_count} products featured
({p.line_products_featured_30d} in last 30d)
</p>
<p className="text-xs text-muted-foreground">
Line last featured {p.line_days_since_featured}d ago
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)
}
function LastFeaturedCell({ p }: { p: Product }) {
if (p.last_featured_at) {
return <span>{p.days_since_featured === 0 ? "Today" : `${p.days_since_featured}d ago`}</span>
}
if (p.line_last_featured_at) {
const lineLabel = p.line_days_since_featured === 0 ? "Today" : `${p.line_days_since_featured}d ago`
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="flex items-center justify-center gap-1 text-blue-500">
<Layers className="h-3 w-3" />
<span>{lineLabel}</span>
</TooltipTrigger>
<TooltipContent side="left">
<p className="text-xs">Product never featured directly.</p>
<p className="text-xs">Line "{p.line}" was last featured {lineLabel.toLowerCase()}.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return <span>Never</span>
}
function CopyPidButton({ pid }: { pid: number }) {
const [copied, setCopied] = useState(false)
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
navigator.clipboard.writeText(String(pid))
toast.success(`Copied PID ${pid}`)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}}
>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{copied ? "Copied!" : "Copy product ID"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
interface ScoreBreakdown {
new_boost: number; preorder_boost: number; clearance_boost: number
velocity_boost: number; back_in_stock_boost: number; interest_boost: number
recency_adj: number; over_featured_adj: number; line_saturation_adj: number
price_tier_adj: number; abc_boost: number; stock_penalty: number
}
const SCORE_LABELS: Record<keyof ScoreBreakdown, string> = {
new_boost: "New Product", preorder_boost: "Pre-Order", clearance_boost: "Clearance",
velocity_boost: "Sales Velocity", back_in_stock_boost: "Back in Stock", interest_boost: "Interest",
recency_adj: "Recency", over_featured_adj: "Over-Featured", line_saturation_adj: "Line Saturation",
price_tier_adj: "Price Tier", abc_boost: "ABC Class", stock_penalty: "Stock"
}
function ScoreBreakdownTooltip({ pid, score, children }: { pid: number; score: number; children: React.ReactNode }) {
const [hovered, setHovered] = useState(false)
const { data } = useQuery<ScoreBreakdown>({
queryKey: ["score-breakdown", pid],
queryFn: async () => {
const res = await fetch(`${config.apiUrl}/newsletter/score-breakdown/${pid}`)
if (!res.ok) throw new Error("Failed")
return res.json()
},
enabled: hovered,
staleTime: 60_000,
})
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild onMouseEnter={() => setHovered(true)}>
{children}
</TooltipTrigger>
<TooltipContent side="right" className="p-0">
<div className="p-2 min-w-[180px]">
<p className="text-xs font-semibold mb-1.5 border-b pb-1">Score Breakdown: {score}</p>
{data ? (
<div className="space-y-0.5">
{(Object.keys(SCORE_LABELS) as (keyof ScoreBreakdown)[]).map(k => {
const v = Number(data[k])
if (v === 0) return null
return (
<div key={k} className="flex justify-between text-xs gap-4">
<span className="text-muted-foreground">{SCORE_LABELS[k]}</span>
<span className={v > 0 ? "text-green-600" : "text-red-500"}>{v > 0 ? "+" : ""}{v}</span>
</div>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground">Loading</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
type SortColumn = "score" | "brand" | "price" | "stock" | "sales_7d" | "sales_30d" | "times_featured" | "days_since_featured"
type SortDirection = "asc" | "desc" | null
interface SortState { column: SortColumn | null; direction: SortDirection }
function toggleSort(prev: SortState, column: SortColumn): SortState {
if (prev.column !== column) return { column, direction: "asc" }
if (prev.direction === "asc") return { column, direction: "desc" }
return { column: null, direction: null }
}
function SortableHeader({ label, column, sort, onSort, className }: {
label: string; column: SortColumn; sort: SortState; onSort: (c: SortColumn) => void; className?: string
}) {
const active = sort.column === column
return (
<TableHead className={`${className ?? ""} cursor-pointer select-none`} onClick={() => onSort(column)}>
<div className={`flex items-center gap-1 ${className?.includes("text-right") ? "justify-end" : className?.includes("text-center") ? "justify-center" : ""}`}>
<span>{label}</span>
{active && sort.direction === "asc" ? <ArrowUp className="h-3 w-3" /> :
active && sort.direction === "desc" ? <ArrowDown className="h-3 w-3" /> :
<ArrowUpDown className="h-3 w-3 opacity-30" />}
</div>
</TableHead>
)
}
export function RecommendationTable({ category }: RecommendationTableProps) {
const { user } = useContext(AuthContext)
const canDebug = user?.is_admin || user?.permissions?.includes("admin:debug")
const [page, setPage] = useState(1)
const [sort, setSort] = useState<SortState>({ column: null, direction: null })
const limit = 100
const { data, isLoading } = useQuery<RecommendationResponse>({
queryKey: ["newsletter-recommendations", category, page],
queryFn: async () => {
const res = await fetch(
`${config.apiUrl}/newsletter/recommendations?category=${category}&page=${page}&limit=${limit}`
)
if (!res.ok) throw new Error("Failed to fetch recommendations")
return res.json()
},
})
const products = useMemo(() => {
const list = data?.products ?? []
if (!sort.column || !sort.direction) return list
const col = sort.column
const dir = sort.direction === "asc" ? 1 : -1
return [...list].sort((a, b) => {
let av: number, bv: number
switch (col) {
case "score": av = a.score; bv = b.score; break
case "brand": return dir * (a.brand ?? "").localeCompare(b.brand ?? "")
case "price": av = Number(a.is_daily_deal && a.deal_price ? a.deal_price : a.price); bv = Number(b.is_daily_deal && b.deal_price ? b.deal_price : b.price); break
case "stock": av = a.current_stock ?? 0; bv = b.current_stock ?? 0; break
case "sales_7d": av = a.sales_7d ?? 0; bv = b.sales_7d ?? 0; break
case "sales_30d": av = a.sales_30d ?? 0; bv = b.sales_30d ?? 0; break
case "times_featured": av = a.times_featured ?? 0; bv = b.times_featured ?? 0; break
case "days_since_featured": av = a.effective_days_since_featured ?? 9999; bv = b.effective_days_since_featured ?? 9999; break
default: return 0
}
return dir * (av - bv)
})
}, [data?.products, sort.column, sort.direction])
const pagination = data?.pagination
if (isLoading) {
return <div className="text-center py-12 text-muted-foreground">Loading recommendations</div>
}
return (
<div>
<div className="rounded-md border overflow-x-auto">
<Table className="">
<TableHeader>
<TableRow>
<SortableHeader label="Score" column="score" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="w-[50px]" />
<TableHead className="w-[60px]">Image</TableHead>
<TableHead>Product</TableHead>
<SortableHeader label="Brand" column="brand" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} />
<SortableHeader label="Price" column="price" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
<SortableHeader label="Stock" column="stock" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
<SortableHeader label="7d Sales" column="sales_7d" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
<SortableHeader label="30d Sales" column="sales_30d" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
<TableHead>Tags</TableHead>
<SortableHeader label="Featured" column="times_featured" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
<SortableHeader label="Last Featured" column="days_since_featured" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} />
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.length === 0 ? (
<TableRow>
<TableCell colSpan={12} className="text-center py-8 text-muted-foreground">
No products found for this category
</TableCell>
</TableRow>
) : (
products.map((p) => (
<TableRow key={p.pid}>
<TableCell>
{canDebug ? (
<ScoreBreakdownTooltip pid={p.pid} score={p.score}>
<span className={`font-mono font-bold text-sm cursor-help ${
p.score >= 40 ? "text-green-600" :
p.score >= 20 ? "text-yellow-600" :
"text-muted-foreground"
}`}>
{p.score}
</span>
</ScoreBreakdownTooltip>
) : (
<span className={`font-mono font-bold text-sm ${
p.score >= 40 ? "text-green-600" :
p.score >= 20 ? "text-yellow-600" :
"text-muted-foreground"
}`}>
{p.score}
</span>
)}
</TableCell>
<TableCell>
{p.image ? (
<img src={p.image} alt="" className="w-10 h-10 object-cover rounded" />
) : (
<div className="w-10 h-10 bg-muted rounded" />
)}
</TableCell>
<TableCell>
<div className="max-w-[400px]">
<p className="font-medium text-sm line-clamp-2">{p.title}</p>
{p.line && (
<p className="text-[10px] text-muted-foreground/70 truncate">{p.line}</p>
)}
</div>
</TableCell>
<TableCell className="text-sm">{p.brand}</TableCell>
<TableCell className="text-center">
<div>
{p.is_daily_deal && p.deal_price ? (
<>
<span className="text-sm font-medium">${Number(p.deal_price).toFixed(2)}</span>
<div>
<span className="text-xs text-muted-foreground line-through">
${Number(p.price).toFixed(2)}
</span>
<span className="text-xs text-red-500 ml-1">
-{Math.round((1 - Number(p.deal_price) / Number(p.price)) * 100)}%
</span>
</div>
</>
) : (
<>
<span className="text-sm font-medium">${Number(p.price).toFixed(2)}</span>
{p.is_clearance && (
<div>
<span className="text-xs text-muted-foreground line-through">
${Number(p.regular_price).toFixed(2)}
</span>
<span className="text-xs text-red-500 ml-1">-{p.discount_pct}%</span>
</div>
)}
</>
)}
</div>
</TableCell>
<TableCell className="text-center">
<span className={`text-sm ${p.current_stock <= 0 ? "text-red-500" : p.is_low_stock ? "text-yellow-600" : ""}`}>
{p.current_stock ?? 0}
</span>
{p.preorder_count > 0 && (
<span className="text-xs text-blue-500 ml-1">(+{p.preorder_count})</span>
)}
</TableCell>
<TableCell className="text-center text-sm">{p.sales_7d ?? 0}</TableCell>
<TableCell className="text-center text-sm">{p.sales_30d ?? 0}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{p.is_new && <Badge variant="default" className="text-[10px] px-1.5 py-0 whitespace-nowrap">New</Badge>}
{p.is_preorder && <Badge variant="secondary" className="text-[10px] px-1.5 py-0 whitespace-nowrap">Pre-Order</Badge>}
{p.is_clearance && <Badge variant="destructive" className="text-[10px] px-1.5 py-0 whitespace-nowrap">Clearance</Badge>}
{p.is_daily_deal && <Badge variant="destructive" className="text-[10px] px-1.5 py-0 bg-orange-500">Deal</Badge>}
{p.is_back_in_stock && <Badge variant="outline" className="text-[10px] px-1.5 py-0 whitespace-nowrap">Back in Stock</Badge>}
{p.is_low_stock && <Badge variant="outline" className="text-[10px] px-1.5 py-0 border-yellow-500 whitespace-nowrap">Low Stock</Badge>}
{(p.baskets > 0 || p.notifies > 0) && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-purple-500">
{p.baskets > 0 ? `${p.baskets} 🛒` : ""}{p.baskets > 0 && p.notifies > 0 ? " " : ""}{p.notifies > 0 ? `${p.notifies} 🔔` : ""}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-center text-sm">
<FeaturedCell p={p} />
</TableCell>
<TableCell className="text-center text-sm text-muted-foreground">
<LastFeaturedCell p={p} />
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<CopyPidButton pid={p.pid} />
{p.permalink && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
asChild
>
<a href={p.permalink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
Open in shop
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{pagination && pagination.pages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
{pagination.total.toLocaleString()} products
</p>
<div className="flex items-center gap-2">
<Button
variant="outline" size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm">
Page {page} of {pagination.pages}
</span>
<Button
variant="outline" size="sm"
disabled={page >= pagination.pages}
onClick={() => setPage(page + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}

View File

@@ -4,7 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import config from "@/config"
import { formatCurrency } from "@/lib/utils"
import { formatCurrency } from "@/utils/formatCurrency"
interface Product {
pid: number;
@@ -22,7 +22,6 @@ interface Category {
units_sold: number;
revenue: string;
profit: string;
growth_rate: string;
}
interface BestSellerBrand {
@@ -39,14 +38,22 @@ interface BestSellersData {
categories: Category[]
}
function TableSkeleton() {
return (
<div className="space-y-3 p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
))}
</div>
);
}
export function BestSellers() {
const { data } = useQuery<BestSellersData>({
const { data, isError, isLoading } = useQuery<BestSellersData>({
queryKey: ["best-sellers"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/best-sellers`)
if (!response.ok) {
throw new Error("Failed to fetch best sellers")
}
if (!response.ok) throw new Error("Failed to fetch best sellers");
return response.json()
},
})
@@ -65,8 +72,14 @@ export function BestSellers() {
</div>
</CardHeader>
<CardContent>
{isError ? (
<p className="text-sm text-destructive">Failed to load best sellers</p>
) : isLoading ? (
<TableSkeleton />
) : (
<>
<TabsContent value="products">
<ScrollArea className="h-[385px] w-full">
<ScrollArea className="h-[420px] w-full">
<Table>
<TableHeader>
<TableRow>
@@ -128,7 +141,9 @@ export function BestSellers() {
{formatCurrency(Number(brand.profit))}
</TableCell>
<TableCell className="w-[15%] text-right">
{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}%
{brand.growth_rate != null ? (
<>{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}%</>
) : '-'}
</TableCell>
</TableRow>
))}
@@ -168,6 +183,8 @@ export function BestSellers() {
</Table>
</ScrollArea>
</TabsContent>
</>
)}
</CardContent>
</Tabs>
</>

View File

@@ -0,0 +1,294 @@
import { useQuery } from "@tanstack/react-query"
import { BarChart, Bar, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip, Cell, LineChart, Line } from "recharts"
import config from "@/config"
import { Target, TrendingDown, ArrowUpDown } from "lucide-react"
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { PHASE_CONFIG } from "@/utils/lifecyclePhases"
interface OverallMetrics {
sampleSize: number
totalActual: number
totalForecast: number
mae: number | null
wmape: number | null
bias: number | null
rmse: number | null
}
interface PhaseAccuracy {
phase: string
sampleSize: number
totalActual: number
totalForecast: number
mae: number | null
wmape: number | null
bias: number | null
rmse: number | null
}
interface LeadTimeAccuracy {
bucket: string
sampleSize: number
mae: number | null
wmape: number | null
bias: number | null
rmse: number | null
}
interface AccuracyTrendPoint {
date: string
mae: number | null
wmape: number | null
bias: number | null
sampleSize: number
}
interface AccuracyData {
hasData: boolean
message?: string
computedAt?: string
daysOfHistory?: number
historyRange?: { from: string; to: string }
overall?: OverallMetrics
byPhase?: PhaseAccuracy[]
byLeadTime?: LeadTimeAccuracy[]
byMethod?: { method: string; sampleSize: number; mae: number | null; wmape: number | null; bias: number | null }[]
dailyTrend?: { date: string; mae: number | null; wmape: number | null; bias: number | null }[]
accuracyTrend?: AccuracyTrendPoint[]
}
function MetricSkeleton() {
return <div className="h-7 w-16 animate-pulse rounded bg-muted" />;
}
function formatWmape(wmape: number | null): string {
if (wmape === null) return "N/A"
return `${wmape.toFixed(1)}%`
}
function formatBias(bias: number | null): string {
if (bias === null) return "N/A"
const sign = bias > 0 ? "+" : ""
return `${sign}${bias.toFixed(3)}`
}
function getAccuracyColor(wmape: number | null): string {
if (wmape === null) return "text-muted-foreground"
if (wmape <= 30) return "text-green-600"
if (wmape <= 50) return "text-yellow-600"
return "text-red-600"
}
export function ForecastAccuracy() {
const { data, error, isLoading } = useQuery<AccuracyData>({
queryKey: ["forecast-accuracy"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/forecast/accuracy`)
if (!response.ok) {
throw new Error("Failed to fetch forecast accuracy")
}
return response.json()
},
refetchInterval: 5 * 60 * 1000,
})
if (error) {
return (
<div>
<h3 className="text-lg font-medium mb-3">Forecast Accuracy</h3>
<p className="text-sm text-destructive">Failed to load accuracy data</p>
</div>
)
}
if (!isLoading && data && !data.hasData) {
return (
<div>
<h3 className="text-lg font-medium mb-3">Forecast Accuracy</h3>
<p className="text-sm text-muted-foreground">
Accuracy data will be available after the forecast engine has run for at least 2 days,
building up historical comparisons between predictions and actual sales.
</p>
</div>
)
}
const phaseChartData = (data?.byPhase || [])
.filter(p => p.wmape !== null && p.phase !== 'dormant')
.map(p => ({
phase: PHASE_CONFIG[p.phase]?.label || p.phase,
rawPhase: p.phase,
wmape: p.wmape,
mae: p.mae,
bias: p.bias,
sampleSize: p.sampleSize,
}))
.sort((a, b) => (a.wmape ?? 100) - (b.wmape ?? 100))
const leadTimeData = (data?.byLeadTime || []).map(lt => ({
bucket: lt.bucket,
wmape: lt.wmape,
mae: lt.mae,
sampleSize: lt.sampleSize,
}))
return (
<div>
<h3 className="text-lg font-medium mb-3">Forecast Accuracy</h3>
{isLoading ? (
<div className="flex flex-col gap-4">
<MetricSkeleton />
<MetricSkeleton />
</div>
) : (
<>
{/* Headline metrics */}
<div className="flex flex-col gap-4">
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Target className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">WMAPE</p>
</div>
<p className={`text-lg font-bold ${getAccuracyColor(data?.overall?.wmape ?? null)}`}>
{formatWmape(data?.overall?.wmape ?? null)}
</p>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<TrendingDown className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">MAE</p>
</div>
<p className="text-lg font-bold">
{data?.overall?.mae !== null ? data?.overall?.mae?.toFixed(2) : "N/A"}
<span className="text-xs font-normal text-muted-foreground ml-1">units</span>
</p>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Bias</p>
</div>
<p className="text-lg font-bold">
{formatBias(data?.overall?.bias ?? null)}
<span className="text-xs font-normal text-muted-foreground ml-1">
{(data?.overall?.bias ?? 0) > 0 ? "over" : (data?.overall?.bias ?? 0) < 0 ? "under" : ""}
</span>
</p>
</div>
</div>
{/* Phase accuracy bar */}
{phaseChartData.length > 0 && (
<div className="mt-4 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">WMAPE by Lifecycle Phase</p>
<TooltipProvider delayDuration={0}>
<div className="space-y-1">
{phaseChartData.map((p) => {
const cfg = PHASE_CONFIG[p.rawPhase] || { label: p.phase, color: "#94A3B8" }
const maxWmape = Math.max(...phaseChartData.map(d => d.wmape ?? 0), 1)
const barWidth = ((p.wmape ?? 0) / maxWmape) * 100
return (
<UITooltip key={p.rawPhase}>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground w-16 text-right shrink-0">{cfg.label}</span>
<div className="flex-1 h-3 bg-muted rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${barWidth}%`,
backgroundColor: cfg.color,
minWidth: barWidth > 0 ? 4 : 0,
}}
/>
</div>
<span className="text-[10px] font-medium w-10 text-right shrink-0">
{formatWmape(p.wmape)}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<div className="font-medium">{cfg.label}</div>
<div>WMAPE: {formatWmape(p.wmape)}</div>
<div>MAE: {p.mae?.toFixed(3) ?? "N/A"} units</div>
<div>Bias: {formatBias(p.bias)}</div>
<div className="text-muted-foreground">{p.sampleSize.toLocaleString()} samples</div>
</TooltipContent>
</UITooltip>
)
})}
</div>
</TooltipProvider>
</div>
)}
{/* Lead time accuracy chart */}
{leadTimeData.length > 0 && (
<div className="mt-4 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Accuracy by Lead Time</p>
<div className="h-[120px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={leadTimeData} margin={{ top: 5, right: 0, left: -30, bottom: 0 }}>
<XAxis
dataKey="bucket"
tickLine={false}
axisLine={false}
tick={{ fontSize: 10 }}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={{ fontSize: 10 }}
tickFormatter={(v) => `${v}%`}
/>
<RechartsTooltip
formatter={(value: number) => [`${value?.toFixed(1)}%`, "WMAPE"]}
/>
<Bar dataKey="wmape" radius={[4, 4, 0, 0]}>
{leadTimeData.map((entry, index) => (
<Cell
key={index}
fill={(entry.wmape ?? 0) <= 30 ? "#22C55E" : (entry.wmape ?? 0) <= 50 ? "#F59E0B" : "#EF4444"}
fillOpacity={0.7}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Accuracy trend sparkline */}
{data?.accuracyTrend && data.accuracyTrend.length > 1 && (
<div className="mt-4 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Accuracy Trend (WMAPE)</p>
<div className="h-[60px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data.accuracyTrend} margin={{ top: 5, right: 0, left: -60, bottom: 0 }}>
<YAxis tickLine={false} axisLine={false} tick={false} />
<Line
type="monotone"
dataKey="wmape"
stroke="#8884D8"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Footer info */}
{data?.daysOfHistory !== undefined && (
<p className="text-[10px] text-muted-foreground mt-3 mb-2">
Based on {data.daysOfHistory} day{data.daysOfHistory !== 1 ? "s" : ""} of history
{data.overall?.sampleSize ? ` (${data.overall.sampleSize.toLocaleString()} samples)` : ""}
</p>
)}
</>
)}
</div>
)
}

View File

@@ -1,17 +1,50 @@
import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts"
import { useState } from "react"
import config from "@/config"
import { formatCurrency } from "@/lib/utils"
import { TrendingUp, DollarSign } from "lucide-react"
import { DateRange } from "react-day-picker"
import { formatCurrency } from "@/utils/formatCurrency"
import { TrendingUp, DollarSign, Target } from "lucide-react"
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { ForecastAccuracy } from "@/components/overview/ForecastAccuracy"
import { addDays, format } from "date-fns"
import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
import { PHASE_CONFIG, PHASE_KEYS } from "@/utils/lifecyclePhases"
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
}
type Period = 30 | 90 | 'year';
function getEndDate(period: Period): Date {
if (period === 'year') return new Date(new Date().getFullYear(), 11, 31);
return addDays(new Date(), period);
}
interface PhaseData {
phase: string
products: number
units: number
revenue: number
percentage: number
}
interface DailyPhaseData {
date: string
preorder: number
launch: number
decay: number
mature: number
slow_mover: number
dormant: number
}
interface ForecastData {
forecastSales: number
forecastRevenue: string
forecastRevenue: number
confidenceLevel: number
dailyForecasts: {
date: string
@@ -19,6 +52,8 @@ interface ForecastData {
revenue: string
confidence: number
}[]
dailyForecastsByPhase?: DailyPhaseData[]
phaseBreakdown?: PhaseData[]
categoryForecasts: {
category: string
units: number
@@ -28,17 +63,14 @@ interface ForecastData {
}
export function ForecastMetrics() {
const [dateRange, setDateRange] = useState<DateRange>({
from: new Date(),
to: addDays(new Date(), 30),
});
const [period, setPeriod] = useState<Period>(30);
const { data, error, isLoading } = useQuery<ForecastData>({
queryKey: ["forecast-metrics", dateRange],
queryKey: ["forecast-metrics", period],
queryFn: async () => {
const params = new URLSearchParams({
startDate: dateRange.from?.toISOString() || "",
endDate: dateRange.to?.toISOString() || "",
startDate: new Date().toISOString(),
endDate: getEndDate(period).toISOString(),
});
const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?${params}`)
if (!response.ok) {
@@ -50,25 +82,35 @@ export function ForecastMetrics() {
},
})
const hasPhaseData = data?.dailyForecastsByPhase && data.dailyForecastsByPhase.length > 0
return (
<>
<CardHeader className="flex flex-row items-center justify-between pr-5">
<CardTitle className="text-xl font-medium">Forecast</CardTitle>
<div className="w-[230px]">
<DateRangePicker
value={dateRange}
onChange={(range) => {
if (range) setDateRange(range);
}}
future={true}
/>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Target className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[400px]">
<ForecastAccuracy />
</PopoverContent>
</Popover>
<Tabs value={String(period)} onValueChange={(v) => setPeriod(v === 'year' ? 'year' : Number(v) as Period)}>
<TabsList>
<TabsTrigger value="30">30D</TabsTrigger>
<TabsTrigger value="90">90D</TabsTrigger>
<TabsTrigger value="year">EOY</TabsTrigger>
</TabsList>
</Tabs>
</div>
</CardHeader>
<CardContent className="py-0 -mb-2">
{error ? (
<div className="text-sm text-red-500">Error: {error.message}</div>
) : isLoading ? (
<div className="text-sm">Loading forecast metrics...</div>
) : (
<>
<div className="flex flex-col gap-4">
@@ -77,21 +119,71 @@ export function ForecastMetrics() {
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Forecast Sales</p>
</div>
<p className="text-lg font-bold">{data?.forecastSales.toLocaleString() || 0}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.forecastSales.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
</div>
<p className="text-lg font-bold">{formatCurrency(Number(data?.forecastRevenue) || 0)}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.forecastRevenue)}</p>
)}
</div>
</div>
{isLoading ? (
<div className="mt-4 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Forecast Revenue By Lifecycle Phase</p>
<div className="h-2.5 w-full animate-pulse rounded-full bg-muted" />
</div>
) : data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (
<div className="mt-4 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Forecast Revenue By Lifecycle Phase</p>
<TooltipProvider delayDuration={0}>
<div className="flex h-2.5 w-full overflow-hidden rounded-full">
{data.phaseBreakdown.map((p) => {
const cfg = PHASE_CONFIG[p.phase] || { label: p.phase, color: "#94A3B8" }
return (
<UITooltip key={p.phase}>
<TooltipTrigger asChild>
<div
className="h-full transition-all"
style={{
width: `${p.percentage}%`,
backgroundColor: cfg.color,
minWidth: p.percentage > 0 ? 4 : 0,
}}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<div className="flex items-center gap-1.5 font-medium">
<div className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: cfg.color }} />
{cfg.label}
<span className="font-normal opacity-70">{p.percentage}%</span>
</div>
<div className="mt-0.5 font-semibold">{formatCurrency(p.revenue)}</div>
<div className="opacity-70">{p.products.toLocaleString()} products</div>
</TooltipContent>
</UITooltip>
)
})}
</div>
</TooltipProvider>
</div>
)}
<div className="h-[250px] w-full">
{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={data?.dailyForecasts || []}
data={hasPhaseData ? data.dailyForecastsByPhase : (data?.dailyForecasts || [])}
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
>
<XAxis
@@ -105,10 +197,31 @@ export function ForecastMetrics() {
axisLine={false}
tick={false}
/>
<Tooltip
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
<RechartsTooltip
formatter={(value: number, name: string) => {
const cfg = PHASE_CONFIG[name]
return [formatCurrency(value), cfg?.label || name]
}}
labelFormatter={(date) => format(new Date(date + 'T00:00:00'), 'MMM d, yyyy')}
itemSorter={(item) => -(item.value as number || 0)}
/>
{hasPhaseData ? (
PHASE_KEYS.map((phase) => {
const cfg = PHASE_CONFIG[phase]
return (
<Area
key={phase}
type="monotone"
dataKey={phase}
name={phase}
stackId="a"
stroke={cfg.color}
fill={cfg.color}
fillOpacity={0.6}
/>
)
})
) : (
<Area
type="monotone"
dataKey="revenue"
@@ -117,8 +230,10 @@ export function ForecastMetrics() {
fill="#8884D8"
fillOpacity={0.2}
/>
)}
</AreaChart>
</ResponsiveContainer>
)}
</div>
</>
)}

View File

@@ -1,32 +1,46 @@
import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import config from "@/config"
import { formatCurrency } from "@/lib/utils"
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react"
import { formatCurrency } from "@/utils/formatCurrency"
import { AlertTriangle, Layers, DollarSign, Tag } from "lucide-react"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { PHASE_CONFIG } from "@/utils/lifecyclePhases"
interface PhaseBreakdown {
phase: string
products: number
units: number
cost: number
retail: number
percentage: number
}
interface OverstockMetricsData {
overstockedProducts: number
total_excess_units: number
total_excess_cost: number
total_excess_retail: number
category_data: {
totalExcessUnits: number
totalExcessCost: number
totalExcessRetail: number
categoryData: {
category: string
products: number
units: number
cost: number
retail: number
}[]
phaseBreakdown?: PhaseBreakdown[]
}
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
}
export function OverstockMetrics() {
const { data } = useQuery<OverstockMetricsData>({
const { data, isError, isLoading } = useQuery<OverstockMetricsData>({
queryKey: ["overstock-metrics"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`)
if (!response.ok) {
throw new Error("Failed to fetch overstock metrics")
}
return response.json()
if (!response.ok) throw new Error('Failed to fetch overstock metrics');
return response.json();
},
})
@@ -36,36 +50,83 @@ export function OverstockMetrics() {
<CardTitle className="text-xl font-medium">Overstock</CardTitle>
</CardHeader>
<CardContent>
{isError ? (
<p className="text-sm text-destructive">Failed to load overstock metrics</p>
) : (
<div className="flex flex-col gap-4">
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground" />
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Overstocked Products</p>
</div>
<p className="text-lg font-bold">{data?.overstockedProducts.toLocaleString() || 0}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.overstockedProducts.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
</div>
<p className="text-lg font-bold">{data?.total_excess_units.toLocaleString() || 0}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.totalExcessUnits.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">Overstocked Cost</p>
</div>
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_cost || 0)}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.totalExcessCost)}</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" />
<Tag className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
</div>
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_retail || 0)}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.totalExcessRetail)}</p>
)}
</div>
{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>
)}
</CardContent>
</>
)

View File

@@ -1,66 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import config from '../../config';
interface SalesData {
date: string;
total: number;
}
export function Overview() {
const { data, isLoading, error } = useQuery<SalesData[]>({
queryKey: ['sales-overview'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/sales-overview`);
if (!response.ok) {
throw new Error('Failed to fetch sales overview');
}
const rawData = await response.json();
return rawData.map((item: SalesData) => ({
...item,
total: parseFloat(item.total.toString()),
date: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}));
},
});
if (isLoading) {
return <div>Loading chart...</div>;
}
if (error) {
return <div className="text-red-500">Error loading sales overview</div>;
}
return (
<ResponsiveContainer width="100%" height={350}>
<LineChart data={data}>
<XAxis
dataKey="date"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `$${value.toLocaleString()}`}
/>
<Tooltip
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales']}
labelFormatter={(label) => `Date: ${label}`}
/>
<Line
type="monotone"
dataKey="total"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -2,16 +2,16 @@ import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
import config from "@/config"
import { formatCurrency } from "@/lib/utils"
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons
import { formatCurrency } from "@/utils/formatCurrency"
import { ClipboardList, AlertCircle, Truck, DollarSign, Tag } from "lucide-react"
import { useState } from "react"
interface PurchaseMetricsData {
activePurchaseOrders: number // Orders that are not canceled, done, or fully received
overduePurchaseOrders: number // Orders past their expected delivery date
onOrderUnits: number // Total units across all active orders
onOrderCost: number // Total cost across all active orders
onOrderRetail: number // Total retail value across all active orders
activePurchaseOrders: number
overduePurchaseOrders: number
onOrderUnits: number
onOrderCost: number
onOrderRetail: number
vendorOrders: {
vendor: string
orders: number
@@ -35,7 +35,6 @@ const COLORS = [
const renderActiveShape = (props: any) => {
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, vendor, cost } = props;
// Split vendor name into words and create lines of max 12 chars
const words = vendor.split(' ');
const lines: string[] = [];
let currentLine = '';
@@ -52,125 +51,108 @@ const renderActiveShape = (props: any) => {
return (
<g>
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
<Sector
cx={cx}
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={outerRadius - 1}
outerRadius={outerRadius + 4}
fill={fill}
/>
<Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill} />
<Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius - 1} outerRadius={outerRadius + 4} fill={fill} />
{lines.map((line, i) => (
<text
key={i}
x={cx}
y={cy}
dy={-20 + (i * 16)}
textAnchor="middle"
fill="#888888"
className="text-xs"
>
<text key={i} x={cx} y={cy} dy={-20 + (i * 16)} textAnchor="middle" fill="#888888" className="text-xs">
{line}
</text>
))}
<text
x={cx}
y={cy}
dy={lines.length * 16 - 10}
textAnchor="middle"
fill="#000000"
className="text-base font-medium"
>
<text x={cx} y={cy} dy={lines.length * 16 - 10} textAnchor="middle" fill="#000000" className="text-base font-medium">
{formatCurrency(cost)}
</text>
</g>
);
};
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
}
export function PurchaseMetrics() {
const [activeIndex, setActiveIndex] = useState<number | undefined>();
const { data, error, isLoading } = useQuery<PurchaseMetricsData>({
const { data, isError, isLoading } = useQuery<PurchaseMetricsData>({
queryKey: ["purchase-metrics"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/purchase/metrics`)
if (!response.ok) {
const text = await response.text();
console.error('API Error:', text);
throw new Error(`Failed to fetch purchase metrics: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data;
if (!response.ok) throw new Error('Failed to fetch purchase metrics');
return response.json();
},
})
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading purchase metrics</div>;
return (
<>
<CardHeader>
<CardTitle className="text-xl font-medium">Purchases</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-between gap-8">
<div className="flex-1">
<div className="flex flex-col gap-4">
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Active Purchase Orders</p>
{isError ? (
<p className="text-sm text-destructive">Failed to load purchase metrics</p>
) : (
<div className="flex gap-4">
<div className="shrink-0">
<div className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-4">
<div className="flex min-w-0 items-center gap-2">
<ClipboardList className="h-4 w-4 shrink-0 text-muted-foreground" />
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Active Purchase Orders</p>
</div>
<p className="text-lg font-bold">{data?.activePurchaseOrders.toLocaleString() || 0}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.activePurchaseOrders.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p>
<div 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>
<p className="text-lg font-bold">{data?.overduePurchaseOrders.toLocaleString() || 0}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.overduePurchaseOrders.toLocaleString()}</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 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>
<p className="text-lg font-bold">{data?.onOrderUnits.toLocaleString() || 0}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.onOrderUnits.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">On Order Cost</p>
<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>
<p className="text-lg font-bold">{formatCurrency(data?.onOrderCost || 0)}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.onOrderCost)}</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 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>
<p className="text-lg font-bold">{formatCurrency(data?.onOrderRetail || 0)}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.onOrderRetail)}</p>
)}
</div>
</div>
</div>
<div className="flex-1">
<div className="min-w-0 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="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 || []}
data={data.vendorOrders}
dataKey="cost"
nameKey="vendor"
cx="50%"
@@ -183,7 +165,7 @@ export function PurchaseMetrics() {
onMouseEnter={(_, index) => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(undefined)}
>
{data?.vendorOrders?.map((entry, index) => (
{data.vendorOrders.map((entry, index) => (
<Cell
key={entry.vendor}
fill={COLORS[index % COLORS.length]}
@@ -192,10 +174,12 @@ export function PurchaseMetrics() {
</Pie>
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
</div>
)}
</CardContent>
</>
)

View File

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

View File

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

View File

@@ -2,23 +2,34 @@ import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
import config from "@/config"
import { formatCurrency } from "@/lib/utils"
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react"
import { formatCurrency } from "@/utils/formatCurrency"
import { Package, PackageCheck, Layers, DollarSign, Tag } from "lucide-react"
import { useState } from "react"
import { PHASE_CONFIG } from "@/utils/lifecyclePhases"
interface PhaseStock {
phase: string
products: number
units: number
cost: number
retail: number
percentage: number
}
interface StockMetricsData {
totalProducts: number
productsInStock: number
totalStockUnits: number
totalStockCost: string
totalStockRetail: string
totalStockCost: number
totalStockRetail: number
brandStock: {
brand: string
variants: number
units: number
cost: string
retail: string
cost: number
retail: number
}[]
phaseStock?: PhaseStock[]
}
const COLORS = [
@@ -32,146 +43,149 @@ const COLORS = [
"#FF7C43",
]
const renderActiveShape = (props: any) => {
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, brand, retail } = props;
// Split brand name into words and create lines of max 12 chars
const words = brand.split(' ');
function wrapLabel(text: string, maxLen = 12): string[] {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
let cur = '';
words.forEach((word: string) => {
if ((currentLine + ' ' + word).length <= 12) {
currentLine = currentLine ? `${currentLine} ${word}` : word;
if ((cur + ' ' + word).length <= maxLen) {
cur = cur ? `${cur} ${word}` : word;
} else {
if (currentLine) lines.push(currentLine);
currentLine = word;
if (cur) lines.push(cur);
cur = word;
}
});
if (currentLine) lines.push(currentLine);
if (cur) lines.push(cur);
return lines;
}
const renderActiveShape = (props: any) => {
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, brand, cost } = props;
const lines = wrapLabel(brand);
return (
<g>
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
<Sector
cx={cx}
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={outerRadius - 1}
outerRadius={outerRadius + 4}
fill={fill}
/>
<Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill} />
<Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius - 1} outerRadius={outerRadius + 4} fill={fill} />
{lines.map((line, i) => (
<text
key={i}
x={cx}
y={cy}
dy={-20 + (i * 16)}
textAnchor="middle"
fill="#888888"
className="text-xs"
>
{line}
</text>
<text key={i} x={cx} y={cy} dy={-20 + (i * 16)} textAnchor="middle" fill="#888888" className="text-xs">{line}</text>
))}
<text
x={cx}
y={cy}
dy={lines.length * 16 - 10}
textAnchor="middle"
fill="#000000"
className="text-base font-medium"
>
{formatCurrency(Number(retail))}
<text x={cx} y={cy} dy={lines.length * 16 - 10} textAnchor="middle" fill="#000000" className="text-base font-medium">
{formatCurrency(cost)}
</text>
</g>
);
};
const renderPhaseActiveShape = (props: any) => {
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, phase, cost } = props;
const cfg = PHASE_CONFIG[phase] || { label: phase };
const lines = wrapLabel(cfg.label);
return (
<g>
<Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill} />
<Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius - 1} outerRadius={outerRadius + 4} fill={fill} />
{lines.map((line, i) => (
<text key={i} x={cx} y={cy} dy={-20 + (i * 16)} textAnchor="middle" fill="#888888" className="text-xs">{line}</text>
))}
<text x={cx} y={cy} dy={lines.length * 16 - 10} textAnchor="middle" fill="#000000" className="text-base font-medium">
{formatCurrency(cost)}
</text>
</g>
);
};
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
}
export function StockMetrics() {
const [activeIndex, setActiveIndex] = useState<number | undefined>();
const [activePhaseIndex, setActivePhaseIndex] = useState<number | undefined>();
const { data, error, isLoading } = useQuery<StockMetricsData>({
const { data, isError, isLoading } = useQuery<StockMetricsData>({
queryKey: ["stock-metrics"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`);
if (!response.ok) {
const text = await response.text();
console.error('API Error:', text);
throw new Error(`Failed to fetch stock metrics: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data;
if (!response.ok) throw new Error('Failed to fetch stock metrics');
return response.json();
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading stock metrics</div>;
return (
<>
<CardHeader>
<CardTitle className="text-xl font-medium">Stock</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-between gap-8">
<div className="flex-1">
<div className="flex flex-col gap-4">
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Products</p>
{isError ? (
<p className="text-sm text-destructive">Failed to load stock metrics</p>
) : (
<div className="flex gap-4">
<div className="shrink-0">
<div className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-4">
<div className="flex min-w-0 items-center gap-2">
<Package className="h-4 w-4 shrink-0 text-muted-foreground" />
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Products</p>
</div>
<p className="text-lg font-bold">{data?.totalProducts.toLocaleString() || 0}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.totalProducts.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Products In Stock</p>
<div 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>
<p className="text-lg font-bold">{data?.productsInStock.toLocaleString() || 0}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.productsInStock.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Stock Units</p>
<div className="flex 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>
<p className="text-lg font-bold">{data?.totalStockUnits.toLocaleString() || 0}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.totalStockUnits.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">Stock Cost</p>
<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>
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockCost) || 0)}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.totalStockCost)}</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 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>
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockRetail) || 0)}</p>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.totalStockRetail)}</p>
)}
</div>
</div>
</div>
<div className="flex-1">
<div className="flex flex-col gap-1">
<div className="text-md flex justify-center font-medium">Stock Retail By Brand</div>
<div className="flex min-w-0 flex-1 gap-2">
<div className="flex flex-1 flex-col gap-1">
<div className="text-md flex justify-center font-medium">Stock Cost By Brand</div>
<div className="h-[180px]">
{isLoading || !data ? (
<div className="flex h-full items-center justify-center">
<div className="h-[160px] w-[160px] animate-pulse rounded-full bg-muted" />
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data?.brandStock || []}
dataKey="retail"
data={data.brandStock}
dataKey="cost"
nameKey="brand"
cx="50%"
cy="50%"
@@ -183,7 +197,7 @@ export function StockMetrics() {
onMouseEnter={(_, index) => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(undefined)}
>
{data?.brandStock?.map((entry, index) => (
{data.brandStock.map((entry, index) => (
<Cell
key={entry.brand}
fill={COLORS[index % COLORS.length]}
@@ -192,10 +206,48 @@ export function StockMetrics() {
</Pie>
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
<div className="flex flex-1 flex-col gap-1">
<div className="text-md flex justify-center font-medium">Stock Cost By Phase</div>
<div className="h-[180px]">
{isLoading || !data?.phaseStock ? (
<div className="flex h-full items-center justify-center">
<div className="h-[160px] w-[160px] animate-pulse rounded-full bg-muted" />
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data.phaseStock}
dataKey="cost"
nameKey="phase"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={1}
activeIndex={activePhaseIndex}
activeShape={renderPhaseActiveShape}
onMouseEnter={(_, index) => setActivePhaseIndex(index)}
onMouseLeave={() => setActivePhaseIndex(undefined)}
>
{data.phaseStock.map((entry) => {
const cfg = PHASE_CONFIG[entry.phase] || { color: "#94A3B8" }
return (
<Cell key={entry.phase} fill={cfg.color} />
)
})}
</Pie>
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
</div>
)}
</CardContent>
</>
)

View File

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

View File

@@ -3,6 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import config from "@/config"
import { format } from "date-fns"
interface Product {
pid: number;
@@ -14,14 +15,22 @@ interface Product {
last_purchase_date: string | null;
}
function TableSkeleton() {
return (
<div className="space-y-3 p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
))}
</div>
);
}
export function TopReplenishProducts() {
const { data } = useQuery<Product[]>({
const { data, isError, isLoading } = useQuery<Product[]>({
queryKey: ["top-replenish-products"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`)
if (!response.ok) {
throw new Error("Failed to fetch products to replenish")
}
if (!response.ok) throw new Error("Failed to fetch products to replenish");
return response.json()
},
})
@@ -32,7 +41,12 @@ export function TopReplenishProducts() {
<CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="max-h-[530px] w-full overflow-y-auto">
{isError ? (
<p className="text-sm text-destructive">Failed to load replenish products</p>
) : isLoading ? (
<TableSkeleton />
) : (
<ScrollArea className="max-h-[630px] w-full overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
@@ -60,12 +74,13 @@ export function TopReplenishProducts() {
<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>
<TableCell className="whitespace-nowrap">{product.last_purchase_date ? format(new Date(product.last_purchase_date), 'M/dd/yyyy') : '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent>
</>
)

View File

@@ -1,79 +0,0 @@
import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Progress } from "@/components/ui/progress"
import config from "@/config"
interface VendorMetrics {
vendor: string
avg_lead_time: number
on_time_delivery_rate: number
avg_fill_rate: number
total_orders: number
active_orders: number
overdue_orders: number
}
export function VendorPerformance() {
const { data: vendors } = useQuery<VendorMetrics[]>({
queryKey: ["vendor-metrics"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/vendor/performance`)
if (!response.ok) {
throw new Error("Failed to fetch vendor metrics")
}
return response.json()
},
})
// Sort vendors by on-time delivery rate
const sortedVendors = vendors
?.sort((a, b) => b.on_time_delivery_rate - a.on_time_delivery_rate)
return (
<>
<CardHeader>
<CardTitle className="text-lg font-medium">Top Vendor Performance</CardTitle>
</CardHeader>
<CardContent className="max-h-[400px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Vendor</TableHead>
<TableHead>On-Time</TableHead>
<TableHead className="text-right">Fill Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedVendors?.map((vendor) => (
<TableRow key={vendor.vendor}>
<TableCell className="font-medium">{vendor.vendor}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress
value={vendor.on_time_delivery_rate}
className="h-2"
/>
<span className="w-10 text-sm">
{vendor.on_time_delivery_rate.toFixed(0)}%
</span>
</div>
</TableCell>
<TableCell className="text-right">
{vendor.avg_fill_rate.toFixed(0)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</>
)
}

View File

@@ -17,6 +17,7 @@ export function EditableInput({
copyable,
alwaysShowCopy,
formatDisplay,
rightAction,
}: {
value: string;
onChange: (val: string) => void;
@@ -30,6 +31,7 @@ export function EditableInput({
copyable?: boolean;
alwaysShowCopy?: boolean;
formatDisplay?: (val: string) => string;
rightAction?: React.ReactNode;
}) {
const [editing, setEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -106,6 +108,7 @@ export function EditableInput({
<Copy className="h-3 w-3" />
</button>
)}
{rightAction}
</div>
);
}

View File

@@ -175,7 +175,7 @@ function SortableImageCell({
src={src}
alt={`Image ${image.iid}`}
className={cn(
"w-full h-full object-cover pointer-events-none select-none",
"w-full h-full object-contain pointer-events-none select-none",
isMain ? "rounded-lg" : "rounded-md"
)}
draggable={false}

View File

@@ -7,7 +7,11 @@ import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink } from "lucide-react";
import { 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 { EditableComboboxField } from "./EditableComboboxField";
import { EditableInput } from "./EditableInput";
@@ -207,6 +211,8 @@ export function ProductEditForm({
handleSubmit,
reset,
watch,
setValue,
getValues,
formState: { dirtyFields },
} = useForm<ProductFormValues>();
@@ -382,10 +388,12 @@ export function ProductEditForm({
originalImagesRef.current = [...productImages];
reset(data);
} else {
toast.error(result.message ?? "Failed to update product");
if (result.error) {
console.error("Edit error details:", result.error);
}
const errorDetail = Array.isArray(result.error)
? result.error.filter((e) => e !== "Errors").join("; ")
: typeof result.error === "string"
? result.error
: null;
toast.error(errorDetail || result.message || "Failed to update product");
}
} catch (err) {
toast.error(
@@ -409,6 +417,62 @@ export function ProductEditForm({
[fieldOptions, lineOptions, sublineOptions]
);
// --- AI inline validation ---
const [validatingField, setValidatingField] = useState<"name" | "description" | null>(null);
const [descDialogOpen, setDescDialogOpen] = useState(false);
const {
validateName,
validateDescription,
nameResult,
descriptionResult,
clearNameResult,
clearDescriptionResult,
} = useInlineAiValidation();
const handleValidateName = useCallback(async () => {
const values = getValues();
if (!values.name?.trim()) return;
clearNameResult();
setValidatingField("name");
const companyLabel = fieldOptions.companies.find((c) => c.value === values.company)?.label;
const lineLabel = lineOptions.find((l) => l.value === values.line)?.label;
const sublineLabel = sublineOptions.find((s) => s.value === values.subline)?.label;
const result = await validateName({
name: values.name,
company_name: companyLabel,
company_id: values.company,
line_name: lineLabel,
subline_name: sublineLabel,
});
setValidatingField((prev) => (prev === "name" ? null : prev));
if (result && result.isValid && !result.suggestion) {
toast.success("Name looks good!");
}
}, [getValues, fieldOptions, lineOptions, sublineOptions, validateName, clearNameResult]);
const handleValidateDescription = useCallback(async () => {
const values = getValues();
if (!values.description?.trim()) 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 changedCount = Object.keys(dirtyFields).length;
@@ -481,9 +545,42 @@ export function ProductEditForm({
);
}
if (fc.type === "textarea") {
const isDescription = fc.key === "description";
return (
<div key={fc.key} className="col-span-full flex flex-col gap-0.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm hover:border-input hover:bg-muted/50 transition-colors">
<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" />
</div>
);
@@ -497,7 +594,7 @@ export function ProductEditForm({
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0 flex items-center gap-1">
<div className="flex-1 min-w-0 flex items-start gap-1">
<div className="flex-1 min-w-0">
<Controller
name="name"
@@ -510,9 +607,40 @@ export function ProductEditForm({
placeholder="Product name"
className="text-base font-semibold"
inputClassName="text-base font-semibold"
rightAction={
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 text-purple-500 hover:text-purple-600 transition-colors"
onClick={(e) => { e.stopPropagation(); handleValidateName(); }}
disabled={validatingField === "name"}
>
{validatingField === "name"
? <Loader2 className="h-4 w-4 animate-spin" />
: <Sparkles className="h-4 w-4" />
}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">AI validate name</TooltipContent>
</Tooltip>
}
/>
)}
/>
{nameResult?.suggestion && (
<AiSuggestionBadge
suggestion={nameResult.suggestion}
issues={nameResult.issues}
onAccept={() => {
setValue("name", nameResult.suggestion!, { shouldDirty: true });
clearNameResult();
}}
onDismiss={clearNameResult}
compact
className="mt-1"
/>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
@@ -609,6 +737,36 @@ export function ProductEditForm({
renderFieldGroup(group, gi + MODE_LAYOUTS[layoutMode].sidebarGroups)
)}
{/* AI Description Review Dialog */}
{descriptionResult?.suggestion && (
<Dialog open={descDialogOpen} onOpenChange={setDescDialogOpen}>
<DialogContent className="sm:max-w-4xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-500" />
AI Description Review
</DialogTitle>
</DialogHeader>
<AiDescriptionCompare
currentValue={getValues("description")}
onCurrentChange={(v) => setValue("description", v, { shouldDirty: true })}
suggestion={descriptionResult.suggestion}
issues={descriptionResult.issues}
productName={getValues("name")}
onAccept={(text) => {
setValue("description", text, { shouldDirty: true });
clearDescriptionResult();
setDescDialogOpen(false);
}}
onDismiss={() => {
clearDescriptionResult();
setDescDialogOpen(false);
}}
/>
</DialogContent>
</Dialog>
)}
{/* Submit */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button

View File

@@ -193,18 +193,6 @@ export const BASE_IMPORT_FIELDS = [
fieldType: { type: "input" },
width: 120,
},
{
label: "Weight",
key: "weight",
description: "Product weight (in lbs)",
alternateMatches: ["weight (lbs.)"],
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Length",
key: "length",
@@ -238,6 +226,18 @@ export const BASE_IMPORT_FIELDS = [
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Weight",
key: "weight",
description: "Product weight (in lbs)",
alternateMatches: ["weight (lbs.)"],
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Shipping Restrictions",
key: "ship_restrictions",

View File

@@ -45,7 +45,7 @@ interface Props {
data: Product[];
file: File;
onBack?: () => void;
onSubmit: (data: Product[], file: File, options: SubmitOptions) => void | Promise<any>;
onSubmit: (data: Product[], file: File, options: SubmitOptions) => void | Promise<boolean | void>;
}
export const ImageUploadStep = ({
@@ -228,15 +228,17 @@ export const ImageUploadStep = ({
showNewProduct,
};
await onSubmit(updatedData, file, submitOptions);
const success = await onSubmit(updatedData, file, submitOptions);
// Delete the import session on successful submit
// Only delete the import session after a successful submit response
if (success) {
try {
await deleteImportSession();
} catch (err) {
// Non-critical - log but don't fail the submission
console.warn('Failed to delete import session:', err);
}
}
} catch (error) {
console.error('Submit error:', error);
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);

View File

@@ -337,7 +337,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
invalidData: [] as Data<string>[],
all: data as Data<string>[]
};
onSubmit(result, file, options);
return onSubmit(result, file, options);
}}
/>
)

Some files were not shown because too many files have changed in this diff Show More