Compare commits
20 Commits
dd0e989669
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c344fdc3b8 | |||
| ebef903f3b | |||
| 16d2399de8 | |||
| c3e09d5fd1 | |||
| bae8c575bc | |||
| 45ded53530 | |||
| f41b5ab0f6 | |||
| 6834a77a80 | |||
| 38b12c188f | |||
| 6aefc1b40d | |||
| 7c41a7f799 | |||
| 12cc7a4639 | |||
| 9b2f9016f6 | |||
| 8044771301 | |||
| b5469440bf | |||
| fd14af0f9e | |||
| a703019b0b | |||
| 2744e82264 | |||
| 450fd96e19 | |||
| 4372dc5e26 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -80,3 +80,8 @@ chat-migration*/
|
||||
**/chat-migration*/
|
||||
chat-migration*/**
|
||||
**/chat-migration*/**
|
||||
|
||||
venv/
|
||||
venv/**
|
||||
**/venv/*
|
||||
**/venv/**
|
||||
@@ -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
346
docs/METRICS_AUDIT.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# Metrics Calculation Pipeline Audit
|
||||
|
||||
**Date:** 2026-02-07
|
||||
**Scope:** All 6 SQL calculation scripts, custom DB functions, import pipeline, and live data verification
|
||||
|
||||
## Overview
|
||||
|
||||
The metrics pipeline in `inventory-server/scripts/calculate-metrics-new.js` runs 6 SQL scripts sequentially:
|
||||
|
||||
1. `update_daily_snapshots.sql` — Aggregates daily per-product sales/receiving data
|
||||
2. `update_product_metrics.sql` — Calculates the main product_metrics table (KPIs, forecasting, status)
|
||||
3. `update_periodic_metrics.sql` — ABC classification, average lead time
|
||||
4. `calculate_brand_metrics.sql` — Brand-level aggregated metrics
|
||||
5. `calculate_vendor_metrics.sql` — Vendor-level aggregated metrics
|
||||
6. `calculate_category_metrics.sql` — Category-level metrics with hierarchy rollups
|
||||
|
||||
### Database Scale
|
||||
| Table | Row Count |
|
||||
|---|---|
|
||||
| products | 681,912 |
|
||||
| orders | 2,883,982 |
|
||||
| purchase_orders | 256,809 |
|
||||
| receivings | 313,036 |
|
||||
| daily_product_snapshots | 678,312 (601 distinct dates, since 2024-06-01) |
|
||||
| product_metrics | 681,912 |
|
||||
| brand_metrics | 1,789 |
|
||||
| vendor_metrics | 281 |
|
||||
| category_metrics | 610 |
|
||||
|
||||
---
|
||||
|
||||
## Issues Found
|
||||
|
||||
### ISSUE 1: [HIGH] Order status filter is non-functional — numeric codes vs text comparison
|
||||
|
||||
**Files:** `update_daily_snapshots.sql` lines 86-101, `update_product_metrics.sql` lines 89, 178-183
|
||||
**Confirmed by data:** All order statuses are numeric strings ('100', '50', '55', etc.)
|
||||
**Status mappings from:** `docs/prod_registry.class.php`
|
||||
|
||||
**Description:** The SQL filters `COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned')` and `o.status NOT IN ('canceled', 'returned')` are used throughout the pipeline to exclude canceled/returned orders. However, the import pipeline stores order statuses as their **raw numeric codes** from the production MySQL database (e.g., '100', '50', '55', '90', '92'). There are **zero text status values** in the orders table.
|
||||
|
||||
This means these filters **never exclude any rows** — every comparison is `'100' NOT IN ('canceled', 'returned')` which is always true.
|
||||
|
||||
**Actual status distribution (with confirmed meanings):**
|
||||
| Status | Meaning | Count | Negative Qty | Assessment |
|
||||
|---|---|---|---|---|
|
||||
| 100 | shipped | 2,862,792 | 3,352 | Completed — correct to include |
|
||||
| 50 | awaiting_products | 11,109 | 0 | In-progress — not yet shipped |
|
||||
| 55 | shipping_later | 5,689 | 0 | In-progress — not yet shipped |
|
||||
| 56 | shipping_together | 2,863 | 0 | In-progress — not yet shipped |
|
||||
| 90 | awaiting_shipment | 38 | 0 | Near-complete — not yet shipped |
|
||||
| 92 | awaiting_pickup | 71 | 0 | Near-complete — awaiting customer |
|
||||
| 95 | shipped_confirmed | 5 | 0 | Completed — correct to include |
|
||||
| 15 | cancelled | 1 | 0 | Should be excluded |
|
||||
|
||||
**Full status reference (from prod_registry.class.php):**
|
||||
- 0=created, 10=unfinished, **15=cancelled**, 16=combined, 20=placed, 22=placed_incomplete
|
||||
- 30=cancelled_old (historical), 40=awaiting_payment, 50=awaiting_products
|
||||
- 55=shipping_later, 56=shipping_together, 60=ready, 61=flagged
|
||||
- 62=fix_before_pick, 65=manual_picking, 70=in_pt, 80=picked
|
||||
- 90=awaiting_shipment, 91=remote_wait, **92=awaiting_pickup**, 93=fix_before_ship
|
||||
- **95=shipped_confirmed**, **100=shipped**
|
||||
|
||||
**Severity revised to HIGH (from CRITICAL):** Now that we know the actual meanings, no cancelled/refunded orders are being miscounted (only 1 cancelled order exists, status=15). The real concern is twofold:
|
||||
1. **The text-based filter is dead code** — it can never match any row. Either map statuses to text during import (like POs do) or change SQL to use numeric comparisons.
|
||||
2. **~19,775 unfulfilled orders** (statuses 50/55/56/90/92) are counted as completed sales. These are orders in various stages of fulfillment that haven't shipped yet. While most will eventually ship, counting them now inflates current-period metrics. At 0.69% of total orders, the financial impact is modest but the filter should work correctly on principle.
|
||||
|
||||
**Note:** PO statuses ARE properly mapped to text ('canceled', 'done', etc.) in the import pipeline. Only order statuses are numeric.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 2: [CRITICAL] Daily Snapshots use current stock instead of historical EOD stock
|
||||
|
||||
**File:** `update_daily_snapshots.sql`, lines 126-135, 173
|
||||
**Confirmed by data:** Top product (pid 666925) shows `eod_stock_quantity = 0` for ALL dates even though it sold 28 units on Jan 28 (clearly had stock then)
|
||||
|
||||
**Description:** The `CurrentStock` CTE reads `stock_quantity` directly from the `products` table at query execution time. When the script processes historical dates (today minus 1-4 days), it writes **today's stock** as if it were the end-of-day stock for those past dates.
|
||||
|
||||
**Cascading impact on product_metrics:**
|
||||
- `avg_stock_units_30d` / `avg_stock_cost_30d` — Wrong averages
|
||||
- `stockout_days_30d` — Undercounts (only based on current stock state, not historical)
|
||||
- `stockout_rate_30d`, `service_level_30d`, `fill_rate_30d` — All derived from wrong stockout data
|
||||
- `gmroi_30d` — Wrong denominator (avg stock cost)
|
||||
- `stockturn_30d` — Wrong denominator (avg stock units)
|
||||
- `sell_through_30d` — Affected by stock level inaccuracy
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 3: [CRITICAL] Snapshot coverage is 0.17% — most products have no snapshot data
|
||||
|
||||
**Confirmed by data:** 678,312 snapshot rows across 601 dates = ~1,128 products/day out of 681,912 total
|
||||
|
||||
**Description:** The daily snapshots script only creates rows for products with sales or receiving activity on that date (`ProductsWithActivity` CTE, line 136). This means:
|
||||
- 91.1% of products (621,221) have NULL `sales_30d` — they had no orders in the last 30 days so no snapshot rows exist
|
||||
- `AVG(eod_stock_quantity)` averages only across days with activity, not 30 days
|
||||
- `stockout_days_30d` only counts stockout days where there was ALSO some activity
|
||||
- A product out of stock with zero sales gets zero stockout_days even though it was stocked out
|
||||
|
||||
This is by design (to avoid creating 681K rows/day) but means stock-related metrics are systematically biased.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 4: [HIGH] `costeach` fallback to 50% of price in import pipeline
|
||||
|
||||
**File:** `inventory-server/scripts/import/orders.js` (line ~573)
|
||||
|
||||
**Description:** When the MySQL `order_costs` table has no record for an order item, `costeach` defaults to `price * 0.5`. There is **no flag** in the PostgreSQL data to distinguish actual costs from estimated ones.
|
||||
|
||||
**Data impact:** 385,545 products (56.5%) have `current_cost_price = 0` AND `current_landing_cost_price = 0`. For these products, the COGS calculation in daily_snapshots falls through the chain:
|
||||
1. `o.costeach` — May be the 50% estimate from import
|
||||
2. `get_weighted_avg_cost()` — Returns NULL if no receivings exist
|
||||
3. `p.landing_cost_price` — Always NULL (hardcoded in import)
|
||||
4. `p.cost_price` — 0 for 56.5% of products
|
||||
|
||||
Only 27 products have zero COGS with positive sales, meaning the `costeach` field is doing its job for products that sell, but the 50% fallback means margins for those products are estimates, not actuals.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 5: [HIGH] `landing_cost_price` is always NULL
|
||||
|
||||
**File:** `inventory-server/scripts/import/products.js` (line ~175)
|
||||
|
||||
**Description:** The import explicitly sets `landing_cost_price = NULL` for all products. The daily_snapshots COGS calculation uses it as a fallback: `COALESCE(o.costeach, get_weighted_avg_cost(...), p.landing_cost_price, p.cost_price)`. Since it's always NULL, this fallback step is useless and the chain jumps straight to `cost_price`.
|
||||
|
||||
The `product_metrics` field `current_landing_cost_price` is populated as `COALESCE(p.landing_cost_price, p.cost_price, 0.00)`, so it equals `cost_price` for all products. Any UI showing "landing cost" is actually just showing `cost_price`.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 6: [HIGH] Vendor lead time is drastically wrong — missing supplier_id join
|
||||
|
||||
**File:** `calculate_vendor_metrics.sql`, lines 62-82
|
||||
**Confirmed by data:** Vendor-level lead times are 2-10x higher than product-level lead times
|
||||
|
||||
**Description:** The vendor metrics lead time joins POs to receivings only by `pid`:
|
||||
```sql
|
||||
LEFT JOIN public.receivings r ON r.pid = po.pid
|
||||
```
|
||||
But the periodic metrics lead time correctly matches supplier:
|
||||
```sql
|
||||
JOIN public.receivings r ON r.pid = po.pid AND r.supplier_id = po.supplier_id
|
||||
```
|
||||
|
||||
Without supplier matching, a PO for product X from Vendor A can match a receiving of product X from Vendor B, creating inflated/wrong lead times.
|
||||
|
||||
**Measured discrepancies:**
|
||||
| Vendor | Vendor Metrics Lead Time | Avg Product Lead Time |
|
||||
|---|---|---|
|
||||
| doodlebug design inc. | 66 days | 14 days |
|
||||
| Notions | 55 days | 4 days |
|
||||
| Simple Stories | 59 days | 27 days |
|
||||
| Ranger Industries | 31 days | 5 days |
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 7: [MEDIUM] Net revenue does not subtract returns
|
||||
|
||||
**File:** `update_daily_snapshots.sql`, line 184
|
||||
|
||||
**Description:** `net_revenue = gross_revenue - discounts`. Standard accounting: `net_revenue = gross_revenue - discounts - returns`. The `returns_revenue` is calculated separately but not deducted.
|
||||
|
||||
**Data impact:** There are 3,352 orders with negative quantities (returns), totaling -5,499 units. These returns are tracked in `returns_revenue` but not reflected in `net_revenue`, which means all downstream revenue-based metrics are slightly overstated.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 8: [MEDIUM] Lifetime revenue subquery references wrong table columns
|
||||
|
||||
**File:** `update_product_metrics.sql`, lines 323-329
|
||||
|
||||
**Description:** The lifetime revenue estimation fallback queries:
|
||||
```sql
|
||||
SELECT revenue_7d / NULLIF(sales_7d, 0)
|
||||
FROM daily_product_snapshots
|
||||
WHERE pid = ci.pid AND sales_7d > 0
|
||||
```
|
||||
But `daily_product_snapshots` does NOT have `revenue_7d` or `sales_7d` columns — those exist in `product_metrics`. This subquery either errors silently or returns NULL. The effect is that the estimation always falls back to `current_price * total_sold`.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 9: [MEDIUM] Brand/Vendor metrics COGS filter inflates margins
|
||||
|
||||
**Files:** `calculate_brand_metrics.sql` lines 31, `calculate_vendor_metrics.sql` line 32
|
||||
|
||||
**Description:** `SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END)` excludes products with zero COGS. But if a product has sales revenue and zero COGS (missing cost data), the brand/vendor totals will include the revenue but not the COGS, artificially inflating the margin.
|
||||
|
||||
**Data context:** Brand metrics revenue matches product_metrics aggregation exactly for sales counts, but shows small discrepancies in revenue (e.g., Stamperia: $7,613.98 brand vs $7,611.11 actual). These tiny diffs come from the `> 0` filtering excluding products with negative revenue.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 10: [MEDIUM] Extreme margin values from $0.01 price orders
|
||||
|
||||
**Confirmed by data:** 73 products with margin > 100%, 119 with margin < -100%
|
||||
|
||||
**Examples:**
|
||||
| Product | Revenue | COGS | Margin |
|
||||
|---|---|---|---|
|
||||
| Flower Gift Box Die (pid 624756) | $0.02 | $29.98 | -149,800% |
|
||||
| Special Flowers Stamp Set (pid 614513) | $0.01 | $11.97 | -119,632% |
|
||||
|
||||
These are products with extremely low prices (likely samples, promos, or data errors) where the order price was $0.01. The margin calculation is mathematically correct but these outliers skew any aggregate margin statistics.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 11: [MEDIUM] Sell-through rate has edge cases yielding negative/extreme values
|
||||
|
||||
**File:** `update_product_metrics.sql`, lines 358-361
|
||||
**Confirmed by data:** 30 products with negative sell-through, 10 with sell-through > 200%
|
||||
|
||||
**Description:** Beginning inventory is approximated as `current_stock + sales - received + returns`. When inventory adjustments, shrinkage, or manual corrections occur, this approximation breaks. Edge cases:
|
||||
- Products with many manual stock adjustments → negative denominator → negative sell-through
|
||||
- Products with beginning stock near zero but decent sales → sell-through > 100%
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 12: [MEDIUM] `total_sold` uses different status filter than orders import
|
||||
|
||||
**Import pipeline confirmed:**
|
||||
- Orders import: `order_status >= 15` (includes processing/pending orders)
|
||||
- `total_sold` in products: `order_status >= 20` (more restrictive)
|
||||
|
||||
This means `lifetime_sales` (from `total_sold`) is systematically lower than what you'd calculate by summing the orders table. The discrepancy is confirmed:
|
||||
| Product | total_sold | orders sum | Gap |
|
||||
|---|---|---|---|
|
||||
| pid 31286 | 13,786 | 4,241 | 9,545 |
|
||||
| pid 44309 | 11,978 | 3,119 | 8,859 |
|
||||
|
||||
The large gaps are because the orders table only has data from the import start date (~2024), while `total_sold` includes all-time sales from MySQL. This is expected behavior, not a bug, but it means the `lifetime_revenue_quality` flag is important — most products show 'estimated' quality.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 13: [MEDIUM] Category rollup may double-count products in multiple hierarchy levels
|
||||
|
||||
**File:** `calculate_category_metrics.sql`, lines 42-66
|
||||
|
||||
**Description:** The `RolledUpMetrics` CTE uses:
|
||||
```sql
|
||||
dcm.cat_id = ch.cat_id OR dcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
|
||||
```
|
||||
If products are assigned to categories at multiple levels in the same branch (e.g., both "Paper Crafts" and "Scrapbook Paper" which is a child of "Paper Crafts"), those products' metrics would be counted twice in the parent's rollup.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 14: [LOW] `exclude_forecast` removes products from metrics entirely
|
||||
|
||||
**File:** `update_product_metrics.sql`, line 509
|
||||
|
||||
**Description:** `WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL` is on the main INSERT's WHERE clause. Products with `exclude_forecast = TRUE` won't appear in `product_metrics` at all, rather than just having forecast fields nulled. Currently all 681,912 products are in product_metrics so this appears to not affect any products yet.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 15: [LOW] Daily snapshots only look back 5 days
|
||||
|
||||
**File:** `update_daily_snapshots.sql`, line 14 — `_process_days INT := 5`
|
||||
|
||||
If import data arrives late (>5 days), those days will never get snapshots populated. There is a separate `backfill/rebuild_daily_snapshots.sql` for historical rebuilds.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 16: [INFO] Timezone risk in order date import
|
||||
|
||||
**File:** `inventory-server/scripts/import/orders.js`
|
||||
|
||||
MySQL `DATETIME` values are timezone-naive. The import uses `new Date(order.date)` which interprets them using the import server's local timezone. The SSH config specifies `timezone: '-05:00'` for MySQL (always EST). If the import server is in a different timezone, orders near midnight could land on the wrong date in the daily snapshots calculation.
|
||||
|
||||
---
|
||||
|
||||
## Custom Functions Review
|
||||
|
||||
### `calculate_sales_velocity(sales_30d, stockout_days_30d)`
|
||||
- Divides `sales_30d` by effective selling days: `GREATEST(30 - stockout_days, CASE WHEN sales > 0 THEN 14 ELSE 30 END)`
|
||||
- The 14-day floor prevents extreme velocity for products mostly out of stock
|
||||
- **Sound approach** — the only concern is that stockout_days is unreliable (Issues 2, 3)
|
||||
|
||||
### `get_weighted_avg_cost(pid, date)`
|
||||
- Weighted average of last 10 receivings by cost*qty/qty
|
||||
- Returns NULL if no receivings — sound fallback behavior
|
||||
- **Correct implementation**
|
||||
|
||||
### `safe_divide(numerator, denominator)`
|
||||
- Returns NULL on divide-by-zero — **correct**
|
||||
|
||||
### `std_numeric(value, precision)`
|
||||
- Rounds to precision digits — **correct**
|
||||
|
||||
### `classify_demand_pattern(avg_demand, cv)`
|
||||
- Uses coefficient of variation thresholds: ≤0.2 = stable, ≤0.5 = variable, low-volume+high-CV = sporadic, else lumpy
|
||||
- **Reasonable classification**, though only based on 30-day window
|
||||
|
||||
### `detect_seasonal_pattern(pid)`
|
||||
- CROSS JOIN LATERAL (runs per product) — **expensive**: queries `daily_product_snapshots` twice per product
|
||||
- Compares current month average to yearly average — very simplistic
|
||||
- **Functional but could be a performance bottleneck** with 681K products
|
||||
|
||||
### `category_hierarchy` (materialized view)
|
||||
- Recursive CTE building tree from categories — **correct implementation**
|
||||
- Refreshed concurrently before category metrics calculation — **good practice**
|
||||
|
||||
---
|
||||
|
||||
## Data Health Summary
|
||||
|
||||
| Metric | Count | % of Total |
|
||||
|---|---|---|
|
||||
| Products with zero cost_price | 385,545 | 56.5% |
|
||||
| Products with NULL sales_30d | 621,221 | 91.1% |
|
||||
| Products with no lifetime_sales | 321,321 | 47.1% |
|
||||
| Products with zero COGS but positive sales | 27 | <0.01% |
|
||||
| Products with margin > 100% | 73 | <0.01% |
|
||||
| Products with margin < -100% | 119 | <0.01% |
|
||||
| Products with negative sell-through | 30 | <0.01% |
|
||||
| Products with NULL status | 0 | 0% |
|
||||
| Duplicate daily snapshots (same pid+date) | 0 | 0% |
|
||||
| Net revenue formula mismatches | 0 | 0% |
|
||||
|
||||
### ABC Classification Distribution (replenishable products only)
|
||||
| Class | Products | Revenue % |
|
||||
|---|---|---|
|
||||
| A | 7,727 | 80.72% |
|
||||
| B | 12,048 | 15.10% |
|
||||
| C | 113,647 | 4.18% |
|
||||
|
||||
ABC distribution looks healthy — A ≈ 80%, A+B ≈ 96%.
|
||||
|
||||
### Brand Metrics Consistency
|
||||
Product counts and sales_30d match exactly between `brand_metrics` and direct aggregation from `product_metrics`. Revenue shows sub-dollar discrepancies due to the `> 0` filter excluding products with negative revenue. **Consistent within expected tolerance.**
|
||||
|
||||
---
|
||||
|
||||
## Priority Recommendations
|
||||
|
||||
### Must Fix (Correctness Issues)
|
||||
1. **Issue 1: Fix order status handling** — The text-based filter (`NOT IN ('canceled', 'returned')`) is dead code against numeric statuses. Two options: (a) map numeric statuses to text during import (like POs already do), or (b) change SQL to filter on numeric codes (e.g., `o.status::int >= 20` to exclude cancelled/unfinished, or `o.status IN ('100', '95')` for shipped-only). The ~19.7K unfulfilled orders (0.69%) are a minor financial impact but the filter should be functional.
|
||||
2. **Issue 6: Add supplier_id join to vendor lead time** — One-line fix in `calculate_vendor_metrics.sql`
|
||||
3. **Issue 8: Fix lifetime revenue subquery** — Use correct column names from `daily_product_snapshots` (e.g., `net_revenue / NULLIF(units_sold, 0)`)
|
||||
|
||||
### Should Fix (Data Quality)
|
||||
4. **Issue 2/3: Snapshot coverage** — Consider creating snapshot rows for all in-stock products, not just those with activity. Or at minimum, calculate stockout metrics by comparing snapshot existence to product existence.
|
||||
5. **Issue 5: Populate landing_cost_price** — If available in the source system, import it. Otherwise remove references to avoid confusion.
|
||||
6. **Issue 7: Subtract returns from net_revenue** — `net_revenue = gross_revenue - discounts - returns_revenue`
|
||||
7. **Issue 9: Remove > 0 filter on COGS** — Use `SUM(pm.cogs_30d)` instead of conditional sums
|
||||
|
||||
### Nice to Fix (Edge Cases)
|
||||
8. **Issue 4: Flag estimated costs** — Add a `costeach_estimated BOOLEAN` to orders during import
|
||||
9. **Issue 10: Cap or flag extreme margins** — Exclude $0.01-price orders from margin calculations
|
||||
10. **Issue 11: Clamp sell-through** — `GREATEST(0, LEAST(sell_through_30d, 200))` or flag outliers
|
||||
11. **Issue 12: Verify category assignment policy** — Check if products are assigned to leaf categories only
|
||||
12. **Issue 13: Category rollup query** — Verify no double-counting with actual data
|
||||
276
docs/METRICS_AUDIT2.md
Normal file
276
docs/METRICS_AUDIT2.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Metrics Pipeline Audit Report
|
||||
|
||||
**Date:** 2026-02-08
|
||||
**Scope:** All 6 SQL scripts in `inventory-server/scripts/metrics-new/`, import pipeline, custom functions, and post-calculation data verification.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The metrics pipeline is architecturally sound and the core calculations are mostly correct. The 30-day sales, revenue, replenishment, and aggregate metrics (brand/vendor/category) all cross-check accurately between the snapshots, product_metrics, and direct orders queries. However, several issues were found ranging from **critical data bugs** to **design limitations** that affect accuracy of specific metrics.
|
||||
|
||||
**Issues found: 13** (3 Critical, 4 Medium, 6 Low/Informational)
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL Issues
|
||||
|
||||
### C1. `net_revenue` in daily snapshots never subtracts returns ($35.6K affected)
|
||||
|
||||
**Location:** `update_daily_snapshots.sql`, line 181
|
||||
**Symptom:** `net_revenue` is stored as `gross_revenue - discounts` but should be `gross_revenue - discounts - returns_revenue`.
|
||||
|
||||
The SQL formula on line 181 appears correct:
|
||||
```sql
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue
|
||||
```
|
||||
|
||||
However, actual data shows `net_revenue = gross_revenue - discounts` for ALL 3,252 snapshots that have returns. Total returns not subtracted: **$35,630.03** across 2,946 products. This may be caused by the `returns_revenue` in the SalesData CTE not properly flowing through to the INSERT, or by a prior version of the code that stored these values differently. The profit column (line 184) has the same issue: `(gross - discounts) - cogs` instead of `(gross - discounts - returns) - cogs`.
|
||||
|
||||
**Impact:** Net revenue and profit are overstated by the amount of returns. This cascades to all metrics derived from snapshots: `revenue_30d`, `profit_30d`, `margin_30d`, `avg_ros_30d`, and all brand/vendor/category aggregate revenue.
|
||||
|
||||
**Recommended fix:** Debug why the returns subtraction isn't taking effect. The formula in the SQL looks correct, so this may be a data-type issue or an execution path issue. After fixing, rebuild snapshots.
|
||||
|
||||
**Status:** Owner will resolve. Code formula is correct; snapshots need rebuilding after prior fix deployment.
|
||||
|
||||
---
|
||||
|
||||
### C2. `eod_stock_quantity` uses CURRENT stock, not historical end-of-day stock
|
||||
|
||||
**Location:** `update_daily_snapshots.sql`, lines 123-132 (CurrentStock CTE)
|
||||
**Symptom:** Every snapshot for a given product shows the same stock quantity regardless of the snapshot date.
|
||||
|
||||
The `CurrentStock` CTE simply reads `stock_quantity` from the `products` table:
|
||||
```sql
|
||||
SELECT pid, stock_quantity, ... FROM public.products
|
||||
```
|
||||
|
||||
This means a snapshot from January 10 shows the SAME stock as today (February 8). Verified in data:
|
||||
- Product 662561: stock = 36 on every date (Feb 1-7)
|
||||
- Product 665397: stock = 25 on every date (Feb 1-7)
|
||||
- All products checked show identical stock across all snapshot dates
|
||||
|
||||
**Impact:** All stock-derived metrics are inaccurate for historical analysis:
|
||||
- `eod_stock_cost`, `eod_stock_retail`, `eod_stock_gross` (all wrong for past dates)
|
||||
- `stockout_flag` (based on current stock, not historical)
|
||||
- `stockout_days_30d` (undercounted since stockout_flag uses current stock)
|
||||
- `avg_stock_units_30d`, `avg_stock_cost_30d` (no variance, just current stock repeated)
|
||||
- `gmroi_30d`, `stockturn_30d` (based on avg_stock which is flat)
|
||||
- `sell_through_30d` (denominator uses current stock assumption)
|
||||
- `service_level_30d`, `fill_rate_30d`
|
||||
|
||||
**This is a known architectural limitation** noted in MEMORY.md. Fixing requires either:
|
||||
1. Storing stock snapshots separately at end-of-day (ideally via a cron job that records stock before any changes)
|
||||
2. Reconstructing historical stock from orders and receivings (complex but possible)
|
||||
|
||||
**Status: FIXED.** MySQL's `snap_product_value` table (daily EOD stock per product since 2012) is now imported into PostgreSQL `stock_snapshots` table via `scripts/import/stock-snapshots.js`. The `CurrentStock` CTE in `update_daily_snapshots.sql` now uses `LEFT JOIN stock_snapshots` for historical stock, falling back to `products.stock_quantity` when no historical data exists. Requires: run import, then rebuild daily snapshots.
|
||||
|
||||
---
|
||||
|
||||
### C3. `ON CONFLICT DO UPDATE WHERE` check skips 91%+ of product_metrics updates
|
||||
|
||||
**Location:** `update_product_metrics.sql`, lines 558-574
|
||||
**Symptom:** 623,205 of 681,912 products (91.4%) have `last_calculated` older than 1 day. 592,369 are over 30 days old. 914 products with active 30-day sales haven't been updated in over 7 days.
|
||||
|
||||
The upsert's `WHERE` clause only updates if specific fields changed:
|
||||
```sql
|
||||
WHERE product_metrics.current_stock IS DISTINCT FROM EXCLUDED.current_stock OR
|
||||
product_metrics.current_price IS DISTINCT FROM EXCLUDED.current_price OR ...
|
||||
```
|
||||
|
||||
Fields NOT checked include: `stockout_days_30d`, `margin_30d`, `gmroi_30d`, `demand_pattern`, `seasonality_index`, `sales_growth_*`, `service_level_30d`, and many others. If a product's stock, price, sales, and revenue haven't changed, the entire row is skipped even though growth metrics, variability, and other derived fields may need updating.
|
||||
|
||||
**Impact:** Most derived metrics (growth, demand patterns, seasonality) are stale for the majority of products. Products with steady sales but unchanged stock/price never get their growth metrics recalculated.
|
||||
|
||||
**Recommended fix:** Either:
|
||||
1. Remove the `WHERE` clause entirely (accept the performance cost of writing all rows every run)
|
||||
2. Add `last_calculated` age check: `OR product_metrics.last_calculated < NOW() - INTERVAL '7 days'`
|
||||
3. Add the missing fields to the change-detection check
|
||||
|
||||
**Status: FIXED.** Added 12 derived fields to the `IS DISTINCT FROM` check (`profit_30d`, `cogs_30d`, `margin_30d`, `stockout_days_30d`, `sell_through_30d`, `sales_growth_30d_vs_prev`, `revenue_growth_30d_vs_prev`, `demand_pattern`, `seasonal_pattern`, `seasonality_index`, `service_level_30d`, `fill_rate_30d`) plus a time-based safety net: `OR product_metrics.last_calculated < NOW() - INTERVAL '1 day'`. This guarantees every row is refreshed at least daily.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM Issues
|
||||
|
||||
### M1. Demand variability calculated only over activity days, not full 30-day window
|
||||
|
||||
**Location:** `update_product_metrics.sql`, DemandVariability CTE (lines 206-223)
|
||||
**Symptom:** Variance, std_dev, and CV are computed over only the days that appear in snapshots (activity days), not the full 30-day period including zero-sales days.
|
||||
|
||||
Example: Product 41141 (Mexican Poppy) sold 102 units in 30 days across only 3 snapshot days (1, 1, 100). The variance/CV is calculated over just those 3 data points instead of 30 (with 27 zero-sales days).
|
||||
|
||||
**Impact:**
|
||||
- CV is computed on sparse data (3-10 points instead of 30), making it statistically unreliable
|
||||
- Products with sporadic large orders appear less variable than they really are
|
||||
- `demand_pattern` classification is affected (stable/variable/sporadic/lumpy)
|
||||
|
||||
**Recommended fix:** Join against a generated 30-day date series and COALESCE missing days to 0 units sold before computing variance/stddev/CV.
|
||||
|
||||
**Status: FIXED.** Rewrote `DemandVariability` CTE to use `generate_series()` for the full 30-day date range, `CROSS JOIN` with distinct PIDs from snapshots, and `LEFT JOIN` actual snapshot data with `COALESCE(dps.units_sold, 0)` for missing days. Variance/stddev/CV now computed over all 30 data points.
|
||||
|
||||
---
|
||||
|
||||
### M2. `costeach` fallback to `price * 0.5` affects 32.5% of recent orders
|
||||
|
||||
**Location:** `orders.js`, line 600 and 634
|
||||
**Symptom:** When no cost record exists in `order_costs`, the import falls back to `price * 0.5`.
|
||||
|
||||
Data shows 9,839 of 30,266 recent orders (32.5%) use this fallback. Among these, 79 paid products have `costeach = 0` because `price = 0 * 0.5 = 0`, even though the product has a real cost_price.
|
||||
|
||||
The daily snapshot has a second line of defense (using `get_weighted_avg_cost()` and then `p.cost_price`), but the orders table's `costeach` column itself contains inaccurate data for ~1/3 of orders.
|
||||
|
||||
**Impact:** COGS calculations at the order level are approximate for 1/3 of orders. The snapshot's fallback chain mitigates this somewhat, but any analytics using `orders.costeach` directly will be affected.
|
||||
|
||||
**Status: FIXED.** Added `products.cost_price` as intermediate fallback: `COALESCE(oc.costeach, p.cost_price, oi.price * 0.5)`. The products table join was added to both the `order_totals` CTE and the outer SELECT in `orders.js`. Requires a full orders re-import to apply retroactively.
|
||||
|
||||
---
|
||||
|
||||
### M3. `lifetime_sales` uses MySQL `total_sold` (status >= 20) but orders import uses status >= 15
|
||||
|
||||
**Location:** `products.js` line 200 vs `orders.js` line 69
|
||||
**Symptom:** `total_sold` in the products table comes from MySQL with `order_status >= 20`, excluding status 15 (canceled) and 16 (combined). But the orders import fetches orders with `order_status >= 15`.
|
||||
|
||||
Verified in MySQL: For product 31286, `total_sold` (>=20) = 13,786 vs (>=15) = 13,905 (difference of 119 units).
|
||||
|
||||
**Impact:** `lifetime_sales` in product_metrics (sourced from `products.total_sold`) slightly understates compared to what the orders table contains. The `lifetime_revenue_quality` field correctly flags most as "estimated" since the orders table only covers ~5 years while `total_sold` is all-time. This is a minor inconsistency (< 1% difference).
|
||||
|
||||
**Status:** Accepted. < 1% difference, not worth the complexity of aligning thresholds.
|
||||
|
||||
---
|
||||
|
||||
### M4. `sell_through_30d` has 868 NULL values and 547 anomalous values for products with sales
|
||||
|
||||
**Location:** `update_product_metrics.sql`, lines 356-361
|
||||
**Formula:** `(sales_30d / (current_stock + sales_30d + returns_units_30d - received_qty_30d)) * 100`
|
||||
|
||||
- 868 products with sales but NULL sell_through (denominator = 0, which happens when `current_stock + sales - received = 0`, i.e. all stock came from receiving and was sold)
|
||||
- 259 products with sell_through > 100%
|
||||
- 288 products with negative sell_through
|
||||
|
||||
**Impact:** Sell-through rate is unreliable for products with significant receiving activity in the same period. The formula tries to approximate "beginning inventory" but the approximation breaks when current stock ≠ actual beginning stock (which is always, per issue C2).
|
||||
|
||||
**Status:** Will improve once C2 fix (historical stock) is deployed and snapshots are rebuilt, since `current_stock` in the formula will then reflect actual beginning inventory.
|
||||
|
||||
---
|
||||
|
||||
## LOW / INFORMATIONAL Issues
|
||||
|
||||
### L1. Snapshots only cover ~1,167 products/day out of 681K
|
||||
|
||||
Only products with order or receiving activity on a given day get snapshots. This is by design (the `ProductsWithActivity` CTE on line 133 of `update_daily_snapshots.sql`), but it means:
|
||||
- 560K+ products have zero snapshot history
|
||||
- Stockout tracking is impossible for products with no sales (they can't appear in snapshots)
|
||||
- The "avg_stock" metrics (avg_stock_units_30d, etc.) only average over activity days, not all 30 days
|
||||
|
||||
This is acceptable for storage efficiency but should be understood when interpreting metrics.
|
||||
|
||||
**Status:** Accepted (by design).
|
||||
|
||||
---
|
||||
|
||||
### L2. `detect_seasonal_pattern` function only compares current month to yearly average
|
||||
|
||||
The seasonality detection is simplistic: it compares current month's avg daily sales to yearly avg. This means:
|
||||
- It can only detect if the CURRENT month is above average, not identify historical seasonal patterns
|
||||
- Running in January vs July will give completely different results for the same product
|
||||
- The "peak_season" field always shows the current month/quarter when seasonal (not the actual peak)
|
||||
|
||||
This is noted as a P5 (low priority) feature and is adequate for a first pass but should not be relied upon for demand planning.
|
||||
|
||||
**Status: FIXED.** Rewrote `detect_seasonal_pattern` function to compare monthly average sales across the full last 12 months. Uses CV across months + peak-to-average ratio for classification: `strong` (CV > 0.5, peak > 150%), `moderate` (CV > 0.3, peak > 120%), `none`. Peak season now identifies the actual highest-sales month. Requires at least 3 months of data. Saved in `db/functions.sql`.
|
||||
|
||||
---
|
||||
|
||||
### L3. Free product with negative revenue in top sellers
|
||||
|
||||
Product 476848 ("Thank You, From ACOT!") shows 254 sales with -$1.00 revenue because one order applied a $1 discount to a $0 product. This is a data oddity, not a calculation bug. Could be addressed by excluding $0-price products from revenue metrics or by data cleanup.
|
||||
|
||||
**Status:** Accepted (data oddity, not a bug).
|
||||
|
||||
---
|
||||
|
||||
### L4. `landing_cost_price` is always NULL
|
||||
|
||||
`current_landing_cost_price` in product_metrics is mapped from `current_effective_cost` which is just `cost_price`. The `landing_cost_price` concept (cost + shipping + duties) is not implemented. The field exists but has no meaningful data.
|
||||
|
||||
**Status: FIXED.** Removed `landing_cost_price` from `db/schema.sql`, `current_landing_cost_price` from `db/metrics-schema-new.sql`, `update_product_metrics.sql`, and `backfill/populate_initial_product_metrics.sql`. Column should be dropped from the live database via `ALTER TABLE`.
|
||||
|
||||
---
|
||||
|
||||
### L5. Custom SQL functions not tracked in version control
|
||||
|
||||
All 6 custom functions (`calculate_sales_velocity`, `get_weighted_avg_cost`, `safe_divide`, `std_numeric`, `classify_demand_pattern`, `detect_seasonal_pattern`) and the `category_hierarchy` materialized view exist only in the database. They are not defined in any migration or schema file in the repository.
|
||||
|
||||
If the database needs to be recreated, these would be lost.
|
||||
|
||||
**Status: FIXED.** All 6 functions and the `category_hierarchy` materialized view definition saved to `inventory-server/db/functions.sql`. File is re-runnable via `psql -f functions.sql`.
|
||||
|
||||
---
|
||||
|
||||
### L6. `get_weighted_avg_cost` limited to last 10 receivings
|
||||
|
||||
The function `LIMIT 10` for performance, but this means products with many small receivings may not accurately reflect the true weighted average cost if the cost has changed significantly beyond the last 10 receiving records.
|
||||
|
||||
**Status: FIXED.** Removed `LIMIT 10` from `get_weighted_avg_cost`. Data shows max receivings per product is 142 (p95 = 11, avg = 3), so performance impact is negligible. Updated definition in `db/functions.sql`.
|
||||
|
||||
---
|
||||
|
||||
## Verification Summary
|
||||
|
||||
### What's Working Correctly
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| 30d sales: product_metrics vs orders vs snapshots | **MATCH** (verified top 10 sellers) |
|
||||
| Replenishment formula: manual calc vs stored | **MATCH** (verified 10 products) |
|
||||
| Brand metrics vs sum of product_metrics | **MATCH** (0 difference across all brands) |
|
||||
| Order status mapping (numeric → text) | **CORRECT** (all statuses mapped, no numeric remain) |
|
||||
| Cost price: PostgreSQL vs MySQL source | **MATCH** (within rounding, verified 5 products) |
|
||||
| total_sold: PostgreSQL vs MySQL source | **MATCH** (verified 5 products) |
|
||||
| Category rollups (rolled-up > direct for parents) | **CORRECT** |
|
||||
| ABC classification distribution | **REASONABLE** (A: 8K, B: 12.5K, C: 113K) |
|
||||
| Lead time calculation (PO → receiving) | **CORRECT** (verified examples) |
|
||||
|
||||
### Data Overview
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total products | 681,912 |
|
||||
| Products in product_metrics | 681,912 (100%) |
|
||||
| Products with 30d sales | 10,291 (1.5%) |
|
||||
| Products with negative profit & revenue | 139 (mostly cost > price) |
|
||||
| Products with negative stock | 0 |
|
||||
| Snapshot date range | 2020-06-18 to 2026-02-08 |
|
||||
| Avg products per snapshot day | 1,167 |
|
||||
| Order date range | 2020-06-18 to 2026-02-08 |
|
||||
| Total orders | 2,885,825 |
|
||||
| 'returned' status orders | 0 (returns via negative quantity only) |
|
||||
|
||||
---
|
||||
|
||||
## Fix Status Summary
|
||||
|
||||
| Issue | Severity | Status | Deployment Action Needed |
|
||||
|-------|----------|--------|--------------------------|
|
||||
| C1 | Critical | Owner resolving | Rebuild daily snapshots |
|
||||
| C2 | Critical | **FIXED** | Run import, rebuild daily snapshots |
|
||||
| C3 | Critical | **FIXED** | Deploy updated `update_product_metrics.sql` |
|
||||
| M1 | Medium | **FIXED** | Deploy updated `update_product_metrics.sql` |
|
||||
| M2 | Medium | **FIXED** | Full orders re-import (`--full`) |
|
||||
| M3 | Medium | Accepted | None |
|
||||
| M4 | Medium | Pending C2 | Will improve after C2 deployment |
|
||||
| L1 | Low | Accepted | None |
|
||||
| L2 | Low | **FIXED** | Deploy `db/functions.sql` to database |
|
||||
| L3 | Low | Accepted | None |
|
||||
| L4 | Low | **FIXED** | `ALTER TABLE` to drop columns |
|
||||
| L5 | Low | **FIXED** | None (file committed) |
|
||||
| L6 | Low | **FIXED** | Deploy `db/functions.sql` to database |
|
||||
|
||||
### Deployment Steps
|
||||
|
||||
1. Deploy `db/functions.sql` to PostgreSQL: `psql -d inventory_db -f db/functions.sql` (L2, L6)
|
||||
2. Run import (includes stock snapshots first load) (C2, M2)
|
||||
3. Drop stale columns: `ALTER TABLE products DROP COLUMN IF EXISTS landing_cost_price; ALTER TABLE product_metrics DROP COLUMN IF EXISTS current_landing_cost_price;` (L4)
|
||||
4. Rebuild daily snapshots (C1, C2)
|
||||
5. Re-run metrics calculation (C3, M1 take effect automatically)
|
||||
1106
docs/prod_registry.class.php
Normal file
1106
docs/prod_registry.class.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
17
inventory-server/db/daily-deals-schema.sql
Normal file
17
inventory-server/db/daily-deals-schema.sql
Normal 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);
|
||||
234
inventory-server/db/functions.sql
Normal file
234
inventory-server/db/functions.sql
Normal file
@@ -0,0 +1,234 @@
|
||||
-- Custom PostgreSQL functions used by the metrics pipeline
|
||||
-- These must exist in the database before running calculate-metrics-new.js
|
||||
--
|
||||
-- To install/update: psql -d inventory_db -f functions.sql
|
||||
-- All functions use CREATE OR REPLACE so they are safe to re-run.
|
||||
|
||||
-- =============================================================================
|
||||
-- safe_divide: Division helper that returns a default value instead of erroring
|
||||
-- on NULL or zero denominators.
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.safe_divide(
|
||||
numerator numeric,
|
||||
denominator numeric,
|
||||
default_value numeric DEFAULT NULL::numeric
|
||||
)
|
||||
RETURNS numeric
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF denominator IS NULL OR denominator = 0 THEN
|
||||
RETURN default_value;
|
||||
ELSE
|
||||
RETURN numerator / denominator;
|
||||
END IF;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- std_numeric: Standardized rounding helper for consistent numeric precision.
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.std_numeric(
|
||||
value numeric,
|
||||
precision_digits integer DEFAULT 2
|
||||
)
|
||||
RETURNS numeric
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF value IS NULL THEN
|
||||
RETURN NULL;
|
||||
ELSE
|
||||
RETURN ROUND(value, precision_digits);
|
||||
END IF;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- calculate_sales_velocity: Daily sales velocity adjusted for stockout days.
|
||||
-- Ensures at least 14-day denominator for products with sales to avoid
|
||||
-- inflated velocity from short windows.
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.calculate_sales_velocity(
|
||||
sales_30d integer,
|
||||
stockout_days_30d integer
|
||||
)
|
||||
RETURNS numeric
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $function$
|
||||
BEGIN
|
||||
RETURN sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - stockout_days_30d,
|
||||
CASE
|
||||
WHEN sales_30d > 0 THEN 14.0 -- If we have sales, ensure at least 14 days denominator
|
||||
ELSE 30.0 -- If no sales, use full period
|
||||
END
|
||||
),
|
||||
0
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- get_weighted_avg_cost: Weighted average cost from receivings up to a given date.
|
||||
-- Uses all non-canceled receivings (no row limit) weighted by quantity.
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.get_weighted_avg_cost(
|
||||
p_pid bigint,
|
||||
p_date date
|
||||
)
|
||||
RETURNS numeric
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
AS $function$
|
||||
DECLARE
|
||||
weighted_cost NUMERIC;
|
||||
BEGIN
|
||||
SELECT
|
||||
CASE
|
||||
WHEN SUM(qty_each) > 0 THEN SUM(cost_each * qty_each) / SUM(qty_each)
|
||||
ELSE NULL
|
||||
END INTO weighted_cost
|
||||
FROM receivings
|
||||
WHERE pid = p_pid
|
||||
AND received_date <= p_date
|
||||
AND status != 'canceled';
|
||||
|
||||
RETURN weighted_cost;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- classify_demand_pattern: Classifies demand based on average demand and
|
||||
-- coefficient of variation (CV). Standard inventory classification:
|
||||
-- zero: no demand
|
||||
-- stable: CV <= 0.2 (predictable, easy to forecast)
|
||||
-- variable: CV <= 0.5 (some variability, still forecastable)
|
||||
-- sporadic: low volume + high CV (intermittent demand)
|
||||
-- lumpy: high volume + high CV (unpredictable bursts)
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.classify_demand_pattern(
|
||||
avg_demand numeric,
|
||||
cv numeric
|
||||
)
|
||||
RETURNS character varying
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF avg_demand IS NULL OR cv IS NULL THEN
|
||||
RETURN NULL;
|
||||
ELSIF avg_demand = 0 THEN
|
||||
RETURN 'zero';
|
||||
ELSIF cv <= 0.2 THEN
|
||||
RETURN 'stable';
|
||||
ELSIF cv <= 0.5 THEN
|
||||
RETURN 'variable';
|
||||
ELSIF avg_demand < 1.0 THEN
|
||||
RETURN 'sporadic';
|
||||
ELSE
|
||||
RETURN 'lumpy';
|
||||
END IF;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- detect_seasonal_pattern: Detects seasonality by comparing monthly average
|
||||
-- sales across the last 12 months. Uses coefficient of variation across months
|
||||
-- and peak-to-average ratio to classify patterns.
|
||||
--
|
||||
-- Returns:
|
||||
-- seasonal_pattern: 'none', 'moderate', or 'strong'
|
||||
-- seasonality_index: peak month avg / overall avg * 100 (100 = no seasonality)
|
||||
-- peak_season: name of peak month (e.g. 'January'), or NULL if none
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.detect_seasonal_pattern(p_pid bigint)
|
||||
RETURNS TABLE(seasonal_pattern character varying, seasonality_index numeric, peak_season character varying)
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_monthly_cv NUMERIC;
|
||||
v_max_month_avg NUMERIC;
|
||||
v_overall_avg NUMERIC;
|
||||
v_monthly_stddev NUMERIC;
|
||||
v_peak_month_num INT;
|
||||
v_data_months INT;
|
||||
v_seasonality_index NUMERIC;
|
||||
v_seasonal_pattern VARCHAR;
|
||||
v_peak_season VARCHAR;
|
||||
BEGIN
|
||||
-- Gather monthly average sales and peak month in a single query
|
||||
SELECT
|
||||
COUNT(*),
|
||||
AVG(month_avg),
|
||||
STDDEV(month_avg),
|
||||
MAX(month_avg),
|
||||
(ARRAY_AGG(mo ORDER BY month_avg DESC))[1]::INT
|
||||
INTO v_data_months, v_overall_avg, v_monthly_stddev, v_max_month_avg, v_peak_month_num
|
||||
FROM (
|
||||
SELECT EXTRACT(MONTH FROM snapshot_date) AS mo, AVG(units_sold) AS month_avg
|
||||
FROM daily_product_snapshots
|
||||
WHERE pid = p_pid AND snapshot_date >= CURRENT_DATE - INTERVAL '365 days'
|
||||
GROUP BY EXTRACT(MONTH FROM snapshot_date)
|
||||
) monthly;
|
||||
|
||||
-- Need at least 3 months of data for meaningful seasonality detection
|
||||
IF v_data_months < 3 OR v_overall_avg IS NULL OR v_overall_avg = 0 THEN
|
||||
RETURN QUERY SELECT 'none'::VARCHAR, 100::NUMERIC, NULL::VARCHAR;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- CV of monthly averages
|
||||
v_monthly_cv := v_monthly_stddev / v_overall_avg;
|
||||
|
||||
-- Seasonality index: peak month avg / overall avg * 100
|
||||
v_seasonality_index := ROUND((v_max_month_avg / v_overall_avg * 100)::NUMERIC, 2);
|
||||
|
||||
IF v_monthly_cv > 0.5 AND v_seasonality_index > 150 THEN
|
||||
v_seasonal_pattern := 'strong';
|
||||
v_peak_season := TRIM(TO_CHAR(TO_DATE(v_peak_month_num::TEXT, 'MM'), 'Month'));
|
||||
ELSIF v_monthly_cv > 0.3 AND v_seasonality_index > 120 THEN
|
||||
v_seasonal_pattern := 'moderate';
|
||||
v_peak_season := TRIM(TO_CHAR(TO_DATE(v_peak_month_num::TEXT, 'MM'), 'Month'));
|
||||
ELSE
|
||||
v_seasonal_pattern := 'none';
|
||||
v_peak_season := NULL;
|
||||
v_seasonality_index := 100;
|
||||
END IF;
|
||||
|
||||
RETURN QUERY SELECT v_seasonal_pattern, v_seasonality_index, v_peak_season;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- category_hierarchy: Materialized view providing a recursive category tree
|
||||
-- with ancestor paths for efficient rollup queries.
|
||||
--
|
||||
-- Refresh after category changes: REFRESH MATERIALIZED VIEW category_hierarchy;
|
||||
-- =============================================================================
|
||||
-- DROP MATERIALIZED VIEW IF EXISTS category_hierarchy;
|
||||
-- CREATE MATERIALIZED VIEW category_hierarchy AS
|
||||
-- WITH RECURSIVE cat_tree AS (
|
||||
-- SELECT cat_id, name, type, parent_id,
|
||||
-- cat_id AS root_id, 0 AS level, ARRAY[cat_id] AS path
|
||||
-- FROM categories
|
||||
-- WHERE parent_id IS NULL
|
||||
-- UNION ALL
|
||||
-- SELECT c.cat_id, c.name, c.type, c.parent_id,
|
||||
-- ct.root_id, ct.level + 1, ct.path || c.cat_id
|
||||
-- FROM categories c
|
||||
-- JOIN cat_tree ct ON c.parent_id = ct.cat_id
|
||||
-- )
|
||||
-- SELECT cat_id, name, type, parent_id, root_id, level, path,
|
||||
-- (SELECT array_agg(unnest ORDER BY unnest DESC)
|
||||
-- FROM unnest(cat_tree.path) unnest
|
||||
-- WHERE unnest <> cat_tree.cat_id) AS ancestor_ids
|
||||
-- FROM cat_tree;
|
||||
--
|
||||
-- CREATE UNIQUE INDEX ON category_hierarchy (cat_id);
|
||||
@@ -80,7 +80,6 @@ CREATE TABLE public.product_metrics (
|
||||
current_price NUMERIC(10, 2),
|
||||
current_regular_price NUMERIC(10, 2),
|
||||
current_cost_price NUMERIC(10, 4), -- Increased precision for cost
|
||||
current_landing_cost_price NUMERIC(10, 4), -- Increased precision for cost
|
||||
current_stock INT NOT NULL DEFAULT 0,
|
||||
current_stock_cost NUMERIC(14, 4) NOT NULL DEFAULT 0.00,
|
||||
current_stock_retail NUMERIC(14, 4) NOT NULL DEFAULT 0.00,
|
||||
@@ -156,9 +155,9 @@ CREATE TABLE public.product_metrics (
|
||||
days_of_stock_closing_stock NUMERIC(10, 2), -- lead_time_closing_stock - days_of_stock_forecast_units
|
||||
replenishment_needed_raw NUMERIC(10, 2), -- planning_period_forecast_units + config_safety_stock - current_stock - on_order_qty
|
||||
replenishment_units INT, -- CEILING(GREATEST(0, replenishment_needed_raw))
|
||||
replenishment_cost NUMERIC(14, 4), -- replenishment_units * COALESCE(current_landing_cost_price, current_cost_price)
|
||||
replenishment_cost NUMERIC(14, 4), -- replenishment_units * current_cost_price
|
||||
replenishment_retail NUMERIC(14, 4), -- replenishment_units * current_price
|
||||
replenishment_profit NUMERIC(14, 4), -- replenishment_units * (current_price - COALESCE(current_landing_cost_price, current_cost_price))
|
||||
replenishment_profit NUMERIC(14, 4), -- replenishment_units * (current_price - current_cost_price)
|
||||
to_order_units INT, -- Apply MOQ/UOM logic to replenishment_units
|
||||
forecast_lost_sales_units NUMERIC(10, 2), -- GREATEST(0, -lead_time_closing_stock)
|
||||
forecast_lost_revenue NUMERIC(14, 4), -- forecast_lost_sales_units * current_price
|
||||
@@ -167,7 +166,7 @@ CREATE TABLE public.product_metrics (
|
||||
sells_out_in_days NUMERIC(10, 1), -- (current_stock + on_order_qty) / sales_velocity_daily
|
||||
replenish_date DATE, -- Calc based on when stock hits safety stock minus lead time
|
||||
overstocked_units INT, -- GREATEST(0, current_stock - config_safety_stock - planning_period_forecast_units)
|
||||
overstocked_cost NUMERIC(14, 4), -- overstocked_units * COALESCE(current_landing_cost_price, current_cost_price)
|
||||
overstocked_cost NUMERIC(14, 4), -- overstocked_units * current_cost_price
|
||||
overstocked_retail NUMERIC(14, 4), -- overstocked_units * current_price
|
||||
is_old_stock BOOLEAN, -- Based on age, last sold, last received, on_order status
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ const RUN_PERIODIC_METRICS = true;
|
||||
const RUN_BRAND_METRICS = true;
|
||||
const RUN_VENDOR_METRICS = true;
|
||||
const RUN_CATEGORY_METRICS = true;
|
||||
const RUN_LIFECYCLE_FORECASTS = true;
|
||||
|
||||
// Maximum execution time for the entire sequence (e.g., 90 minutes)
|
||||
const MAX_EXECUTION_TIME_TOTAL = 90 * 60 * 1000;
|
||||
@@ -592,6 +593,13 @@ async function runAllCalculations() {
|
||||
historyType: 'product_metrics',
|
||||
statusModule: 'product_metrics'
|
||||
},
|
||||
{
|
||||
run: RUN_LIFECYCLE_FORECASTS,
|
||||
name: 'Lifecycle Forecast Update',
|
||||
sqlFile: 'metrics-new/update_lifecycle_forecasts.sql',
|
||||
historyType: 'lifecycle_forecasts',
|
||||
statusModule: 'lifecycle_forecasts'
|
||||
},
|
||||
{
|
||||
run: RUN_PERIODIC_METRICS,
|
||||
name: 'Periodic Metrics Update',
|
||||
|
||||
Binary file not shown.
1619
inventory-server/scripts/forecast/forecast_engine.py
Normal file
1619
inventory-server/scripts/forecast/forecast_engine.py
Normal file
File diff suppressed because it is too large
Load Diff
5
inventory-server/scripts/forecast/requirements.txt
Normal file
5
inventory-server/scripts/forecast/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
numpy>=1.24
|
||||
scipy>=1.10
|
||||
pandas>=2.0
|
||||
psycopg2-binary>=2.9
|
||||
statsmodels>=0.14
|
||||
128
inventory-server/scripts/forecast/run_forecast.js
Normal file
128
inventory-server/scripts/forecast/run_forecast.js
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Forecast Pipeline Orchestrator
|
||||
*
|
||||
* Spawns the Python forecast engine with database credentials from the
|
||||
* environment. Can be run manually, via cron, or integrated into the
|
||||
* existing metrics pipeline.
|
||||
*
|
||||
* Usage:
|
||||
* node run_forecast.js
|
||||
*
|
||||
* Environment:
|
||||
* Reads DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT from
|
||||
* /var/www/html/inventory/.env (or current process env).
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Load .env file if it exists (production path)
|
||||
const envPaths = [
|
||||
'/var/www/html/inventory/.env',
|
||||
path.join(__dirname, '../../.env'),
|
||||
];
|
||||
|
||||
for (const envPath of envPaths) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, 'utf-8');
|
||||
for (const line of envContent.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
const key = trimmed.slice(0, eqIndex);
|
||||
const value = trimmed.slice(eqIndex + 1);
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
console.log(`Loaded env from ${envPath}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify required env vars
|
||||
const required = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
|
||||
const missing = required.filter(k => !process.env[k]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SCRIPT_DIR = __dirname;
|
||||
const PYTHON_SCRIPT = path.join(SCRIPT_DIR, 'forecast_engine.py');
|
||||
const VENV_DIR = path.join(SCRIPT_DIR, 'venv');
|
||||
const REQUIREMENTS = path.join(SCRIPT_DIR, 'requirements.txt');
|
||||
|
||||
// Determine python binary (prefer venv if it exists)
|
||||
function getPythonBin() {
|
||||
const venvPython = path.join(VENV_DIR, 'bin', 'python');
|
||||
if (fs.existsSync(venvPython)) return venvPython;
|
||||
|
||||
// Fall back to system python
|
||||
return 'python3';
|
||||
}
|
||||
|
||||
// Ensure venv and dependencies are installed
|
||||
async function ensureDependencies() {
|
||||
if (!fs.existsSync(path.join(VENV_DIR, 'bin', 'python'))) {
|
||||
console.log('Creating virtual environment...');
|
||||
await runCommand('python3', ['-m', 'venv', VENV_DIR]);
|
||||
}
|
||||
|
||||
// Always run pip install — idempotent, fast when packages already present
|
||||
console.log('Checking dependencies...');
|
||||
const python = path.join(VENV_DIR, 'bin', 'python');
|
||||
await runCommand(python, ['-m', 'pip', 'install', '--quiet', '-r', REQUIREMENTS]);
|
||||
}
|
||||
|
||||
function runCommand(cmd, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
...options,
|
||||
});
|
||||
proc.on('close', code => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${cmd} exited with code ${code}`));
|
||||
});
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const startTime = Date.now();
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Forecast Pipeline - ${new Date().toISOString()}`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
await ensureDependencies();
|
||||
|
||||
const pythonBin = getPythonBin();
|
||||
console.log(`Using Python: ${pythonBin}`);
|
||||
console.log(`Running: ${PYTHON_SCRIPT}`);
|
||||
console.log('');
|
||||
|
||||
await runCommand(pythonBin, [PYTHON_SCRIPT], {
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1', // Real-time output
|
||||
},
|
||||
});
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log('');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Forecast pipeline completed in ${duration}s`);
|
||||
console.log('='.repeat(60));
|
||||
} catch (err) {
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.error(`Forecast pipeline FAILED after ${duration}s:`, err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
51
inventory-server/scripts/forecast/sql/create_tables.sql
Normal file
51
inventory-server/scripts/forecast/sql/create_tables.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Forecasting Pipeline Tables
|
||||
-- Run once to create the schema. Safe to re-run (IF NOT EXISTS).
|
||||
|
||||
-- Precomputed reference decay curves per brand (or brand x category at any hierarchy level)
|
||||
CREATE TABLE IF NOT EXISTS brand_lifecycle_curves (
|
||||
id SERIAL PRIMARY KEY,
|
||||
brand TEXT NOT NULL,
|
||||
root_category TEXT, -- NULL = brand-level fallback curve, else category name
|
||||
cat_id BIGINT, -- NULL = brand-only; else category_hierarchy.cat_id for precise matching
|
||||
category_level SMALLINT, -- NULL = brand-only; 0-3 = hierarchy depth
|
||||
amplitude NUMERIC(10,4), -- A in: sales(t) = A * exp(-λt) + C
|
||||
decay_rate NUMERIC(10,6), -- λ (higher = faster decay)
|
||||
baseline NUMERIC(10,4), -- C (long-tail steady-state daily sales)
|
||||
r_squared NUMERIC(6,4), -- goodness of fit
|
||||
sample_size INT, -- number of products that informed this curve
|
||||
median_first_week_sales NUMERIC(10,2), -- for scaling new launches
|
||||
median_preorder_sales NUMERIC(10,2), -- for scaling pre-order products
|
||||
median_preorder_days NUMERIC(10,2), -- median pre-order accumulation window (days)
|
||||
computed_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(brand, cat_id)
|
||||
);
|
||||
|
||||
-- Per-product daily forecasts (next 90 days, regenerated each run)
|
||||
CREATE TABLE IF NOT EXISTS product_forecasts (
|
||||
pid BIGINT NOT NULL,
|
||||
forecast_date DATE NOT NULL,
|
||||
forecast_units NUMERIC(10,2),
|
||||
forecast_revenue NUMERIC(14,4),
|
||||
lifecycle_phase TEXT, -- preorder, launch, decay, mature, slow_mover, dormant
|
||||
forecast_method TEXT, -- lifecycle_curve, exp_smoothing, velocity, zero
|
||||
confidence_lower NUMERIC(10,2),
|
||||
confidence_upper NUMERIC(10,2),
|
||||
generated_at TIMESTAMP DEFAULT NOW(),
|
||||
PRIMARY KEY (pid, forecast_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pf_date ON product_forecasts(forecast_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_pf_phase ON product_forecasts(lifecycle_phase);
|
||||
|
||||
-- Forecast run history (for monitoring)
|
||||
CREATE TABLE IF NOT EXISTS forecast_runs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
started_at TIMESTAMP NOT NULL,
|
||||
finished_at TIMESTAMP,
|
||||
status TEXT DEFAULT 'running', -- running, completed, failed
|
||||
products_forecast INT,
|
||||
phase_counts JSONB, -- {"launch": 50, "decay": 200, ...}
|
||||
curve_count INT, -- brand curves computed
|
||||
error_message TEXT,
|
||||
duration_seconds NUMERIC(10,2)
|
||||
);
|
||||
@@ -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),
|
||||
|
||||
167
inventory-server/scripts/import/daily-deals.js
Normal file
167
inventory-server/scripts/import/daily-deals.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -65,8 +65,12 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'purchase_orders'"
|
||||
);
|
||||
const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01';
|
||||
// Adjust for mysql2 driver timezone vs MySQL server timezone mismatch
|
||||
const mysqlSyncTime = prodConnection.adjustDateForMySQL
|
||||
? prodConnection.adjustDateForMySQL(lastSyncTime)
|
||||
: lastSyncTime;
|
||||
|
||||
console.log('Purchase Orders: Using last sync time:', lastSyncTime);
|
||||
console.log('Purchase Orders: Using last sync time:', lastSyncTime, '(adjusted:', mysqlSyncTime, ')');
|
||||
|
||||
// Create temp tables for processing
|
||||
await localConnection.query(`
|
||||
@@ -254,7 +258,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
OR p.date_estin > ?
|
||||
)
|
||||
` : ''}
|
||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
|
||||
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
|
||||
|
||||
const totalPOs = poCount[0].total;
|
||||
console.log(`Found ${totalPOs} relevant purchase orders`);
|
||||
@@ -291,7 +295,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
` : ''}
|
||||
ORDER BY p.po_id
|
||||
LIMIT ${PO_BATCH_SIZE} OFFSET ${offset}
|
||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
|
||||
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
|
||||
|
||||
if (poList.length === 0) {
|
||||
allPOsProcessed = true;
|
||||
@@ -426,7 +430,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
OR r.date_created > ?
|
||||
)
|
||||
` : ''}
|
||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime] : []);
|
||||
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime] : []);
|
||||
|
||||
const totalReceivings = receivingCount[0].total;
|
||||
console.log(`Found ${totalReceivings} relevant receivings`);
|
||||
@@ -463,7 +467,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
` : ''}
|
||||
ORDER BY r.receiving_id
|
||||
LIMIT ${PO_BATCH_SIZE} OFFSET ${offset}
|
||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime] : []);
|
||||
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime] : []);
|
||||
|
||||
if (receivingList.length === 0) {
|
||||
allReceivingsProcessed = true;
|
||||
|
||||
188
inventory-server/scripts/import/stock-snapshots.js
Normal file
188
inventory-server/scripts/import/stock-snapshots.js
Normal file
@@ -0,0 +1,188 @@
|
||||
const { outputProgress, formatElapsedTime, calculateRate } = require('../metrics-new/utils/progress');
|
||||
|
||||
const BATCH_SIZE = 5000;
|
||||
|
||||
/**
|
||||
* Imports daily stock snapshots from MySQL's snap_product_value table to PostgreSQL.
|
||||
* This provides historical end-of-day stock quantities per product, dating back to 2012.
|
||||
*
|
||||
* MySQL source table: snap_product_value (date, pid, count, pending, value)
|
||||
* - date: snapshot date (typically yesterday's date, recorded daily by cron)
|
||||
* - pid: product ID
|
||||
* - count: end-of-day stock quantity (sum of product_inventory.count)
|
||||
* - pending: pending/on-order quantity
|
||||
* - value: total inventory value at cost (sum of costeach * count)
|
||||
*
|
||||
* PostgreSQL target table: stock_snapshots (snapshot_date, pid, stock_quantity, pending_quantity, stock_value)
|
||||
*
|
||||
* @param {object} prodConnection - MySQL connection to production DB
|
||||
* @param {object} localConnection - PostgreSQL connection wrapper
|
||||
* @param {boolean} incrementalUpdate - If true, only fetch new snapshots since last import
|
||||
* @returns {object} Import statistics
|
||||
*/
|
||||
async function importStockSnapshots(prodConnection, localConnection, incrementalUpdate = true) {
|
||||
const startTime = Date.now();
|
||||
|
||||
outputProgress({
|
||||
status: 'running',
|
||||
operation: 'Stock snapshots import',
|
||||
message: 'Starting stock snapshots import...',
|
||||
current: 0,
|
||||
total: 0,
|
||||
elapsed: formatElapsedTime(startTime)
|
||||
});
|
||||
|
||||
// Ensure target table exists
|
||||
await localConnection.query(`
|
||||
CREATE TABLE IF NOT EXISTS stock_snapshots (
|
||||
snapshot_date DATE NOT NULL,
|
||||
pid BIGINT NOT NULL,
|
||||
stock_quantity INT NOT NULL DEFAULT 0,
|
||||
pending_quantity INT NOT NULL DEFAULT 0,
|
||||
stock_value NUMERIC(14, 4) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (snapshot_date, pid)
|
||||
)
|
||||
`);
|
||||
|
||||
// Create index for efficient lookups by pid
|
||||
await localConnection.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_snapshots_pid ON stock_snapshots (pid)
|
||||
`);
|
||||
|
||||
// Determine the start date for the import
|
||||
let startDate = '2020-01-01'; // Default: match the orders/snapshots date range
|
||||
if (incrementalUpdate) {
|
||||
const [result] = await localConnection.query(`
|
||||
SELECT MAX(snapshot_date)::text AS max_date FROM stock_snapshots
|
||||
`);
|
||||
if (result.rows[0]?.max_date) {
|
||||
// Start from the day after the last imported date
|
||||
startDate = result.rows[0].max_date;
|
||||
}
|
||||
}
|
||||
|
||||
outputProgress({
|
||||
status: 'running',
|
||||
operation: 'Stock snapshots import',
|
||||
message: `Fetching stock snapshots from MySQL since ${startDate}...`,
|
||||
current: 0,
|
||||
total: 0,
|
||||
elapsed: formatElapsedTime(startTime)
|
||||
});
|
||||
|
||||
// Count total rows to import
|
||||
const [countResult] = await prodConnection.query(
|
||||
`SELECT COUNT(*) AS total FROM snap_product_value WHERE date > ?`,
|
||||
[startDate]
|
||||
);
|
||||
const totalRows = countResult[0].total;
|
||||
|
||||
if (totalRows === 0) {
|
||||
outputProgress({
|
||||
status: 'complete',
|
||||
operation: 'Stock snapshots import',
|
||||
message: 'No new stock snapshots to import',
|
||||
current: 0,
|
||||
total: 0,
|
||||
elapsed: formatElapsedTime(startTime)
|
||||
});
|
||||
return { recordsAdded: 0, recordsUpdated: 0, status: 'complete' };
|
||||
}
|
||||
|
||||
outputProgress({
|
||||
status: 'running',
|
||||
operation: 'Stock snapshots import',
|
||||
message: `Found ${totalRows.toLocaleString()} stock snapshot rows to import`,
|
||||
current: 0,
|
||||
total: totalRows,
|
||||
elapsed: formatElapsedTime(startTime)
|
||||
});
|
||||
|
||||
// Process in batches using date-based pagination (more efficient than OFFSET)
|
||||
let processedRows = 0;
|
||||
let recordsAdded = 0;
|
||||
let currentDate = startDate;
|
||||
|
||||
while (processedRows < totalRows) {
|
||||
// Fetch a batch of dates
|
||||
const [dateBatch] = await prodConnection.query(
|
||||
`SELECT DISTINCT date FROM snap_product_value
|
||||
WHERE date > ? ORDER BY date LIMIT 10`,
|
||||
[currentDate]
|
||||
);
|
||||
|
||||
if (dateBatch.length === 0) break;
|
||||
|
||||
const dates = dateBatch.map(r => r.date);
|
||||
const lastDate = dates[dates.length - 1];
|
||||
|
||||
// Fetch all rows for these dates
|
||||
const [rows] = await prodConnection.query(
|
||||
`SELECT date, pid, count AS stock_quantity, pending AS pending_quantity, value AS stock_value
|
||||
FROM snap_product_value
|
||||
WHERE date > ? AND date <= ?
|
||||
ORDER BY date, pid`,
|
||||
[currentDate, lastDate]
|
||||
);
|
||||
|
||||
if (rows.length === 0) break;
|
||||
|
||||
// Batch insert into PostgreSQL using UNNEST for efficiency
|
||||
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
||||
const batch = rows.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const dates = batch.map(r => r.date);
|
||||
const pids = batch.map(r => r.pid);
|
||||
const quantities = batch.map(r => r.stock_quantity);
|
||||
const pending = batch.map(r => r.pending_quantity);
|
||||
const values = batch.map(r => r.stock_value);
|
||||
|
||||
try {
|
||||
const [result] = await localConnection.query(`
|
||||
INSERT INTO stock_snapshots (snapshot_date, pid, stock_quantity, pending_quantity, stock_value)
|
||||
SELECT * FROM UNNEST(
|
||||
$1::date[], $2::bigint[], $3::int[], $4::int[], $5::numeric[]
|
||||
)
|
||||
ON CONFLICT (snapshot_date, pid) DO UPDATE SET
|
||||
stock_quantity = EXCLUDED.stock_quantity,
|
||||
pending_quantity = EXCLUDED.pending_quantity,
|
||||
stock_value = EXCLUDED.stock_value
|
||||
`, [dates, pids, quantities, pending, values]);
|
||||
|
||||
recordsAdded += batch.length;
|
||||
} catch (err) {
|
||||
console.error(`Error inserting batch at offset ${i} (date range ending ${currentDate}):`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
processedRows += rows.length;
|
||||
currentDate = lastDate;
|
||||
|
||||
outputProgress({
|
||||
status: 'running',
|
||||
operation: 'Stock snapshots import',
|
||||
message: `Imported ${processedRows.toLocaleString()} / ${totalRows.toLocaleString()} rows (through ${currentDate})`,
|
||||
current: processedRows,
|
||||
total: totalRows,
|
||||
elapsed: formatElapsedTime(startTime),
|
||||
rate: calculateRate(processedRows, startTime)
|
||||
});
|
||||
}
|
||||
|
||||
outputProgress({
|
||||
status: 'complete',
|
||||
operation: 'Stock snapshots import',
|
||||
message: `Stock snapshots import complete: ${recordsAdded.toLocaleString()} rows`,
|
||||
current: processedRows,
|
||||
total: totalRows,
|
||||
elapsed: formatElapsedTime(startTime)
|
||||
});
|
||||
|
||||
return {
|
||||
recordsAdded,
|
||||
recordsUpdated: 0,
|
||||
status: 'complete'
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = importStockSnapshots;
|
||||
@@ -48,6 +48,37 @@ async function setupConnections(sshConfig) {
|
||||
stream: tunnel.stream,
|
||||
});
|
||||
|
||||
// Detect MySQL server timezone and calculate correction for the driver timezone mismatch.
|
||||
// The mysql2 driver is configured with timezone: '-05:00' (EST), but the MySQL server
|
||||
// may be in a different timezone (e.g., America/Chicago = CST/CDT). When the driver
|
||||
// formats a JS Date as EST and MySQL interprets it in its own timezone, DATETIME
|
||||
// comparisons can be off. This correction adjusts Date objects before they're passed
|
||||
// to MySQL queries so the formatted string matches the server's local time.
|
||||
const [[{ utcDiffSec }]] = await prodConnection.query(
|
||||
"SELECT TIMESTAMPDIFF(SECOND, NOW(), UTC_TIMESTAMP()) as utcDiffSec"
|
||||
);
|
||||
const mysqlOffsetMs = -utcDiffSec * 1000; // MySQL UTC offset in ms (e.g., -21600000 for CST)
|
||||
const driverOffsetMs = -5 * 3600 * 1000; // Driver's -05:00 in ms (-18000000)
|
||||
const tzCorrectionMs = driverOffsetMs - mysqlOffsetMs;
|
||||
// CST (winter): -18000000 - (-21600000) = +3600000 (1 hour correction needed)
|
||||
// CDT (summer): -18000000 - (-18000000) = 0 (no correction needed)
|
||||
|
||||
if (tzCorrectionMs !== 0) {
|
||||
console.log(`MySQL timezone correction: ${tzCorrectionMs / 1000}s (server offset: ${utcDiffSec}s from UTC)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts a Date/timestamp for the mysql2 driver timezone mismatch before
|
||||
* passing it as a query parameter to MySQL. This ensures that the string
|
||||
* mysql2 generates matches the timezone that DATETIME values are stored in.
|
||||
*/
|
||||
function adjustDateForMySQL(date) {
|
||||
if (!date || tzCorrectionMs === 0) return date;
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return new Date(d.getTime() - tzCorrectionMs);
|
||||
}
|
||||
prodConnection.adjustDateForMySQL = adjustDateForMySQL;
|
||||
|
||||
// Setup PostgreSQL connection pool for local
|
||||
const localPool = new Pool(sshConfig.localDbConfig);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ BEGIN
|
||||
p.visible as is_visible, p.replenishable,
|
||||
COALESCE(p.price, 0.00) as current_price, COALESCE(p.regular_price, 0.00) as current_regular_price,
|
||||
COALESCE(p.cost_price, 0.00) as current_cost_price,
|
||||
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as current_effective_cost, -- Use landing if available, else cost
|
||||
COALESCE(p.cost_price, 0.00) as current_effective_cost,
|
||||
p.stock_quantity as current_stock, -- Use actual current stock for forecast base
|
||||
p.created_at, p.first_received, p.date_last_sold,
|
||||
p.moq,
|
||||
@@ -214,7 +214,7 @@ BEGIN
|
||||
-- Final INSERT/UPDATE statement using all the prepared CTEs
|
||||
INSERT INTO public.product_metrics (
|
||||
pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable,
|
||||
current_price, current_regular_price, current_cost_price, current_landing_cost_price,
|
||||
current_price, current_regular_price, current_cost_price,
|
||||
current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
|
||||
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
|
||||
date_created, date_first_received, date_last_received, date_first_sold, date_last_sold, age_days,
|
||||
@@ -242,7 +242,7 @@ BEGIN
|
||||
SELECT
|
||||
-- Select columns in order, joining all CTEs by pid
|
||||
ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.replenishable,
|
||||
ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost,
|
||||
ci.current_price, ci.current_regular_price, ci.current_cost_price,
|
||||
ci.current_stock, (ci.current_stock * COALESCE(ci.current_effective_cost, 0.00))::numeric(12,2), (ci.current_stock * COALESCE(ci.current_price, 0.00))::numeric(12,2), (ci.current_stock * COALESCE(ci.current_regular_price, 0.00))::numeric(12,2),
|
||||
COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00)::numeric(12,2), (COALESCE(ooi.on_order_qty, 0) * COALESCE(ci.current_price, 0.00))::numeric(12,2), ooi.earliest_expected_date,
|
||||
|
||||
@@ -415,7 +415,7 @@ BEGIN
|
||||
-- *** IMPORTANT: List ALL columns here, ensuring order matches INSERT list ***
|
||||
-- Update ALL columns to ensure entire row is refreshed
|
||||
last_calculated = EXCLUDED.last_calculated, sku = EXCLUDED.sku, title = EXCLUDED.title, brand = EXCLUDED.brand, vendor = EXCLUDED.vendor, image_url = EXCLUDED.image_url, is_visible = EXCLUDED.is_visible, is_replenishable = EXCLUDED.is_replenishable,
|
||||
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price,
|
||||
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price,
|
||||
current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross,
|
||||
on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date,
|
||||
date_created = EXCLUDED.date_created, date_first_received = EXCLUDED.date_first_received, date_last_received = EXCLUDED.date_last_received, date_first_sold = EXCLUDED.date_first_sold, date_last_sold = EXCLUDED.date_last_sold, age_days = EXCLUDED.age_days,
|
||||
|
||||
@@ -10,7 +10,7 @@ DECLARE
|
||||
_date DATE;
|
||||
_count INT;
|
||||
_total_records INT := 0;
|
||||
_begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2024-01-01'); -- Starting point for data rebuild
|
||||
_begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2020-01-01'); -- Starting point: captures all historical order data
|
||||
_end_date DATE := CURRENT_DATE;
|
||||
BEGIN
|
||||
RAISE NOTICE 'Beginning daily snapshots rebuild from % to %. Starting at %', _begin_date, _end_date, _start_time;
|
||||
@@ -36,7 +36,13 @@ BEGIN
|
||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
|
||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted,
|
||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
|
||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs,
|
||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN
|
||||
COALESCE(
|
||||
o.costeach,
|
||||
get_weighted_avg_cost(p.pid, o.date::date),
|
||||
p.cost_price
|
||||
) * o.quantity
|
||||
ELSE 0 END), 0.00) AS cogs,
|
||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue,
|
||||
|
||||
-- Aggregate Returns (Quantity < 0 or Status = Returned)
|
||||
@@ -63,15 +69,17 @@ BEGIN
|
||||
GROUP BY r.pid
|
||||
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
|
||||
),
|
||||
-- Get stock quantities for the day - note this is approximate since we're using current products data
|
||||
-- Use historical stock from stock_snapshots when available,
|
||||
-- falling back to current stock from products table
|
||||
StockData AS (
|
||||
SELECT
|
||||
p.pid,
|
||||
p.stock_quantity,
|
||||
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as effective_cost_price,
|
||||
COALESCE(ss.stock_quantity, p.stock_quantity) AS stock_quantity,
|
||||
COALESCE(ss.stock_value, p.stock_quantity * COALESCE(p.cost_price, 0.00)) AS stock_value,
|
||||
COALESCE(p.price, 0.00) as current_price,
|
||||
COALESCE(p.regular_price, 0.00) as current_regular_price
|
||||
FROM public.products p
|
||||
LEFT JOIN stock_snapshots ss ON p.pid = ss.pid AND ss.snapshot_date = _date
|
||||
)
|
||||
INSERT INTO public.daily_product_snapshots (
|
||||
snapshot_date,
|
||||
@@ -99,9 +107,9 @@ BEGIN
|
||||
_date AS snapshot_date,
|
||||
COALESCE(sd.pid, rd.pid) AS pid,
|
||||
sd.sku,
|
||||
-- Use current stock as approximation, since historical stock data may not be available
|
||||
-- Historical stock from stock_snapshots, falls back to current stock
|
||||
s.stock_quantity AS eod_stock_quantity,
|
||||
s.stock_quantity * s.effective_cost_price AS eod_stock_cost,
|
||||
s.stock_value AS eod_stock_cost,
|
||||
s.stock_quantity * s.current_price AS eod_stock_retail,
|
||||
s.stock_quantity * s.current_regular_price AS eod_stock_gross,
|
||||
(s.stock_quantity <= 0) AS stockout_flag,
|
||||
@@ -111,10 +119,10 @@ BEGIN
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00),
|
||||
COALESCE(sd.discounts, 0.00),
|
||||
COALESCE(sd.returns_revenue, 0.00),
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue,
|
||||
COALESCE(sd.cogs, 0.00),
|
||||
COALESCE(sd.gross_regular_revenue, 0.00),
|
||||
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
|
||||
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
|
||||
-- Receiving metrics
|
||||
COALESCE(rd.units_received, 0),
|
||||
COALESCE(rd.cost_received, 0.00),
|
||||
|
||||
@@ -23,21 +23,21 @@ BEGIN
|
||||
-- Only include products with valid sales data in each time period
|
||||
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
|
||||
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
||||
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||
SUM(COALESCE(pm.revenue_7d, 0)) AS revenue_7d,
|
||||
|
||||
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
|
||||
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
||||
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
|
||||
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
|
||||
SUM(COALESCE(pm.revenue_30d, 0)) AS revenue_30d,
|
||||
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||
|
||||
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
|
||||
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
||||
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
||||
SUM(COALESCE(pm.revenue_365d, 0)) AS revenue_365d,
|
||||
|
||||
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
|
||||
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
||||
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
|
||||
SUM(COALESCE(pm.lifetime_revenue, 0)) AS lifetime_revenue
|
||||
FROM public.product_metrics pm
|
||||
JOIN public.products p ON pm.pid = p.pid
|
||||
GROUP BY brand_group
|
||||
|
||||
@@ -28,8 +28,8 @@ BEGIN
|
||||
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
||||
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
|
||||
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
|
||||
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
||||
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
||||
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
||||
@@ -38,58 +38,56 @@ BEGIN
|
||||
JOIN public.product_metrics pm ON pc.pid = pm.pid
|
||||
GROUP BY pc.cat_id
|
||||
),
|
||||
-- Calculate rolled-up metrics (including all descendant categories)
|
||||
-- Map each category to ALL distinct products in it or any descendant.
|
||||
-- Uses the path array from category_hierarchy: for product P in category C,
|
||||
-- P contributes to C and every ancestor in C's path.
|
||||
-- DISTINCT ensures each (ancestor, pid) pair appears only once, preventing
|
||||
-- double-counting when a product belongs to multiple categories under the same parent.
|
||||
CategoryProducts AS (
|
||||
SELECT DISTINCT
|
||||
ancestor_cat_id,
|
||||
pc.pid
|
||||
FROM public.product_categories pc
|
||||
JOIN category_hierarchy ch ON pc.cat_id = ch.cat_id
|
||||
CROSS JOIN LATERAL unnest(ch.path) AS ancestor_cat_id
|
||||
),
|
||||
-- Calculate rolled-up metrics using deduplicated product sets
|
||||
RolledUpMetrics AS (
|
||||
SELECT
|
||||
ch.cat_id,
|
||||
-- Sum metrics from this category and all its descendants
|
||||
SUM(dcm.product_count) AS product_count,
|
||||
SUM(dcm.active_product_count) AS active_product_count,
|
||||
SUM(dcm.replenishable_product_count) AS replenishable_product_count,
|
||||
SUM(dcm.current_stock_units) AS current_stock_units,
|
||||
SUM(dcm.current_stock_cost) AS current_stock_cost,
|
||||
SUM(dcm.current_stock_retail) AS current_stock_retail,
|
||||
SUM(dcm.sales_7d) AS sales_7d,
|
||||
SUM(dcm.revenue_7d) AS revenue_7d,
|
||||
SUM(dcm.sales_30d) AS sales_30d,
|
||||
SUM(dcm.revenue_30d) AS revenue_30d,
|
||||
SUM(dcm.cogs_30d) AS cogs_30d,
|
||||
SUM(dcm.profit_30d) AS profit_30d,
|
||||
SUM(dcm.sales_365d) AS sales_365d,
|
||||
SUM(dcm.revenue_365d) AS revenue_365d,
|
||||
SUM(dcm.lifetime_sales) AS lifetime_sales,
|
||||
SUM(dcm.lifetime_revenue) AS lifetime_revenue
|
||||
FROM category_hierarchy ch
|
||||
LEFT JOIN DirectCategoryMetrics dcm ON
|
||||
dcm.cat_id = ch.cat_id OR
|
||||
dcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
|
||||
GROUP BY ch.cat_id
|
||||
cp.ancestor_cat_id AS cat_id,
|
||||
COUNT(DISTINCT cp.pid) AS product_count,
|
||||
COUNT(DISTINCT CASE WHEN pm.is_visible THEN cp.pid END) AS active_product_count,
|
||||
COUNT(DISTINCT CASE WHEN pm.is_replenishable THEN cp.pid END) AS replenishable_product_count,
|
||||
SUM(pm.current_stock) AS current_stock_units,
|
||||
SUM(pm.current_stock_cost) AS current_stock_cost,
|
||||
SUM(pm.current_stock_retail) AS current_stock_retail,
|
||||
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
||||
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
||||
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
||||
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
||||
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
||||
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
|
||||
FROM CategoryProducts cp
|
||||
JOIN public.product_metrics pm ON cp.pid = pm.pid
|
||||
GROUP BY cp.ancestor_cat_id
|
||||
),
|
||||
PreviousPeriodCategoryMetrics AS (
|
||||
-- Get previous period metrics for growth calculation
|
||||
-- Previous period rolled up using same deduplicated product sets
|
||||
RolledUpPreviousPeriod AS (
|
||||
SELECT
|
||||
pc.cat_id,
|
||||
cp.ancestor_cat_id AS cat_id,
|
||||
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
|
||||
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
|
||||
THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
|
||||
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
|
||||
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
|
||||
THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
|
||||
FROM public.daily_product_snapshots dps
|
||||
JOIN public.product_categories pc ON dps.pid = pc.pid
|
||||
GROUP BY pc.cat_id
|
||||
),
|
||||
RolledUpPreviousPeriod AS (
|
||||
-- Calculate rolled-up previous period metrics
|
||||
SELECT
|
||||
ch.cat_id,
|
||||
SUM(ppcm.sales_prev_30d) AS sales_prev_30d,
|
||||
SUM(ppcm.revenue_prev_30d) AS revenue_prev_30d
|
||||
FROM category_hierarchy ch
|
||||
LEFT JOIN PreviousPeriodCategoryMetrics ppcm ON
|
||||
ppcm.cat_id = ch.cat_id OR
|
||||
ppcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
|
||||
GROUP BY ch.cat_id
|
||||
FROM CategoryProducts cp
|
||||
JOIN public.daily_product_snapshots dps ON cp.pid = dps.pid
|
||||
GROUP BY cp.ancestor_cat_id
|
||||
),
|
||||
AllCategories AS (
|
||||
-- Ensure all categories are included
|
||||
|
||||
@@ -24,21 +24,21 @@ BEGIN
|
||||
-- Only include products with valid sales data in each time period
|
||||
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
|
||||
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
||||
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||
SUM(COALESCE(pm.revenue_7d, 0)) AS revenue_7d,
|
||||
|
||||
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
|
||||
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
||||
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
|
||||
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
|
||||
SUM(COALESCE(pm.revenue_30d, 0)) AS revenue_30d,
|
||||
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||
|
||||
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
|
||||
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
||||
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
||||
SUM(COALESCE(pm.revenue_365d, 0)) AS revenue_365d,
|
||||
|
||||
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
|
||||
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
||||
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
|
||||
SUM(COALESCE(pm.lifetime_revenue, 0)) AS lifetime_revenue
|
||||
FROM public.product_metrics pm
|
||||
JOIN public.products p ON pm.pid = p.pid
|
||||
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
|
||||
@@ -72,7 +72,7 @@ BEGIN
|
||||
END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs
|
||||
FROM public.purchase_orders po
|
||||
-- Join to receivings table to find when items were received
|
||||
LEFT JOIN public.receivings r ON r.pid = po.pid
|
||||
LEFT JOIN public.receivings r ON r.pid = po.pid AND r.supplier_id = po.supplier_id
|
||||
WHERE po.vendor IS NOT NULL AND po.vendor <> ''
|
||||
AND po.date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year
|
||||
AND po.status = 'done' -- Only calculate lead time on completed POs
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Migration: Map existing numeric order statuses to text values
|
||||
-- Run this ONCE on the production PostgreSQL database after deploying the updated orders import.
|
||||
-- This updates ~2.88M rows. On a busy system, consider running during low-traffic hours.
|
||||
-- The WHERE clause ensures idempotency - only rows with numeric statuses are updated.
|
||||
|
||||
UPDATE orders SET status = CASE status
|
||||
WHEN '0' THEN 'created'
|
||||
WHEN '10' THEN 'unfinished'
|
||||
WHEN '15' THEN 'canceled'
|
||||
WHEN '16' THEN 'combined'
|
||||
WHEN '20' THEN 'placed'
|
||||
WHEN '22' THEN 'placed_incomplete'
|
||||
WHEN '30' THEN 'canceled'
|
||||
WHEN '40' THEN 'awaiting_payment'
|
||||
WHEN '50' THEN 'awaiting_products'
|
||||
WHEN '55' THEN 'shipping_later'
|
||||
WHEN '56' THEN 'shipping_together'
|
||||
WHEN '60' THEN 'ready'
|
||||
WHEN '61' THEN 'flagged'
|
||||
WHEN '62' THEN 'fix_before_pick'
|
||||
WHEN '65' THEN 'manual_picking'
|
||||
WHEN '70' THEN 'in_pt'
|
||||
WHEN '80' THEN 'picked'
|
||||
WHEN '90' THEN 'awaiting_shipment'
|
||||
WHEN '91' THEN 'remote_wait'
|
||||
WHEN '92' THEN 'awaiting_pickup'
|
||||
WHEN '93' THEN 'fix_before_ship'
|
||||
WHEN '95' THEN 'shipped_confirmed'
|
||||
WHEN '100' THEN 'shipped'
|
||||
ELSE status
|
||||
END
|
||||
WHERE status ~ '^\d+$'; -- Only update rows that still have numeric statuses
|
||||
|
||||
-- Verify the migration
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM orders
|
||||
GROUP BY status
|
||||
ORDER BY count DESC;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Migration 002: Fix discount double-counting in orders
|
||||
--
|
||||
-- PROBLEM: The orders import was calculating discount as:
|
||||
-- discount = (prod_price_reg - prod_price) * quantity <-- "sale savings" (WRONG)
|
||||
-- + prorated points discount
|
||||
-- + item-level promo discounts
|
||||
--
|
||||
-- Since `price` in the orders table already IS the sale price (prod_price, not prod_price_reg),
|
||||
-- the "sale savings" component double-counted the markdown. This resulted in inflated discounts
|
||||
-- and near-zero net_revenue for products sold on sale.
|
||||
--
|
||||
-- Example: Product with regular_price=$30, sale_price=$15, qty=2
|
||||
-- BEFORE (buggy): discount = ($30-$15)*2 + 0 + 0 = $30.00
|
||||
-- net_revenue = $15*2 - $30 = $0.00 (WRONG!)
|
||||
-- AFTER (fixed): discount = 0 + 0 + 0 = $0.00
|
||||
-- net_revenue = $15*2 - $0 = $30.00 (CORRECT!)
|
||||
--
|
||||
-- FIX: This cannot be fixed with a pure SQL migration because PostgreSQL doesn't store
|
||||
-- prod_price_reg. The discount column has the inflated value baked in, and we can't
|
||||
-- decompose which portion was the base_discount vs actual promo discounts.
|
||||
--
|
||||
-- REQUIRED ACTION: Run a FULL (non-incremental) orders re-import after deploying the
|
||||
-- fixed orders.js. This will recalculate all discounts using the corrected formula.
|
||||
--
|
||||
-- Steps:
|
||||
-- 1. Deploy updated orders.js (base_discount removed from discount calculation)
|
||||
-- 2. Run: node scripts/import/orders.js --full
|
||||
-- (or trigger a full sync through whatever mechanism is used)
|
||||
-- 3. After re-import, run the daily snapshots rebuild to propagate corrected revenue:
|
||||
-- psql -f scripts/metrics-new/backfill/rebuild_daily_snapshots.sql
|
||||
-- 4. Re-run metrics calculation:
|
||||
-- node scripts/metrics-new/calculate-metrics-new.js
|
||||
--
|
||||
-- VERIFICATION: After re-import, check the previously-affected products:
|
||||
SELECT
|
||||
o.pid,
|
||||
p.title,
|
||||
o.order_number,
|
||||
o.price,
|
||||
o.quantity,
|
||||
o.discount,
|
||||
(o.price * o.quantity) as gross_revenue,
|
||||
(o.price * o.quantity - o.discount) as net_revenue
|
||||
FROM orders o
|
||||
JOIN products p ON o.pid = p.pid
|
||||
WHERE o.pid IN (624756, 614513)
|
||||
ORDER BY o.date DESC
|
||||
LIMIT 10;
|
||||
|
||||
-- Expected: discount should be 0 (or small promo amount) for regular sales,
|
||||
-- and net_revenue should be close to gross_revenue.
|
||||
@@ -1,75 +1,109 @@
|
||||
-- Description: Calculates and updates daily aggregated product data for recent days.
|
||||
-- Uses UPSERT (INSERT ON CONFLICT UPDATE) for idempotency.
|
||||
-- Description: Calculates and updates daily aggregated product data.
|
||||
-- Self-healing: detects gaps (missing snapshots), stale data (snapshot
|
||||
-- aggregates that don't match source tables after backfills), and always
|
||||
-- reprocesses recent days to pick up new orders and data corrections.
|
||||
-- Dependencies: Core import tables (products, orders, purchase_orders), calculate_status table.
|
||||
-- Frequency: Hourly (Run ~5-10 minutes after hourly data import completes).
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
_module_name TEXT := 'daily_snapshots';
|
||||
_start_time TIMESTAMPTZ := clock_timestamp(); -- Time execution started
|
||||
_last_calc_time TIMESTAMPTZ;
|
||||
_target_date DATE; -- Will be set in the loop
|
||||
_start_time TIMESTAMPTZ := clock_timestamp();
|
||||
_target_date DATE;
|
||||
_total_records INT := 0;
|
||||
_has_orders BOOLEAN := FALSE;
|
||||
_process_days INT := 5; -- Number of days to check/process (today plus previous 4 days)
|
||||
_day_counter INT;
|
||||
_missing_days INT[] := ARRAY[]::INT[]; -- Array to store days with missing or incomplete data
|
||||
_days_processed INT := 0;
|
||||
_max_backfill_days INT := 90; -- Safety cap: max days to backfill per run
|
||||
_recent_recheck_days INT := 2; -- Always reprocess this many recent days (today + yesterday)
|
||||
_latest_snapshot DATE;
|
||||
_backfill_start DATE;
|
||||
BEGIN
|
||||
-- Get the timestamp before the last successful run of this module
|
||||
SELECT last_calculation_timestamp INTO _last_calc_time
|
||||
FROM public.calculate_status
|
||||
WHERE module_name = _module_name;
|
||||
|
||||
RAISE NOTICE 'Running % script. Start Time: %', _module_name, _start_time;
|
||||
|
||||
-- First, check which days need processing by comparing orders data with snapshot data
|
||||
FOR _day_counter IN 0..(_process_days-1) LOOP
|
||||
_target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day');
|
||||
-- Find the latest existing snapshot date (for logging only)
|
||||
SELECT MAX(snapshot_date) INTO _latest_snapshot
|
||||
FROM public.daily_product_snapshots;
|
||||
|
||||
-- Check if this date needs updating by comparing orders to snapshot data
|
||||
-- If the date has orders but not enough snapshots, or if snapshots show zero sales but orders exist, it's incomplete
|
||||
SELECT
|
||||
CASE WHEN (
|
||||
-- We have orders for this date but not enough snapshots, or snapshots with wrong total
|
||||
(EXISTS (SELECT 1 FROM public.orders WHERE date::date = _target_date) AND
|
||||
(
|
||||
-- No snapshots exist for this date
|
||||
NOT EXISTS (SELECT 1 FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) OR
|
||||
-- Or snapshots show zero sales but orders exist
|
||||
(SELECT COALESCE(SUM(units_sold), 0) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) = 0 OR
|
||||
-- Or the count of snapshot records is significantly less than distinct products in orders
|
||||
(SELECT COUNT(*) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) <
|
||||
(SELECT COUNT(DISTINCT pid) FROM public.orders WHERE date::date = _target_date) * 0.8
|
||||
)
|
||||
)
|
||||
) THEN TRUE ELSE FALSE END
|
||||
INTO _has_orders;
|
||||
-- Always scan the full backfill window to catch holes in the middle,
|
||||
-- not just gaps at the end. The gap fill and stale detection queries
|
||||
-- need to see the entire range to find missing or outdated snapshots.
|
||||
_backfill_start := CURRENT_DATE - _max_backfill_days;
|
||||
|
||||
IF _has_orders THEN
|
||||
-- This day needs processing - add to our array
|
||||
_missing_days := _missing_days || _day_counter;
|
||||
RAISE NOTICE 'Day % needs updating (incomplete or missing data)', _target_date;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- If no days need updating, exit early
|
||||
IF array_length(_missing_days, 1) IS NULL THEN
|
||||
RAISE NOTICE 'No days need updating - all snapshot data appears complete';
|
||||
|
||||
-- Still update the calculate_status to record this run
|
||||
UPDATE public.calculate_status
|
||||
SET last_calculation_timestamp = _start_time
|
||||
WHERE module_name = _module_name;
|
||||
|
||||
RETURN;
|
||||
IF _latest_snapshot IS NULL THEN
|
||||
RAISE NOTICE 'No existing snapshots found. Backfilling up to % days.', _max_backfill_days;
|
||||
ELSE
|
||||
RAISE NOTICE 'Latest snapshot: %. Scanning from % for gaps and stale data.', _latest_snapshot, _backfill_start;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Need to update % days with missing or incomplete data', array_length(_missing_days, 1);
|
||||
-- Process all dates that need snapshots:
|
||||
-- 1. Gap fill: dates with orders/receivings but no snapshots (older than recent window)
|
||||
-- 2. Stale detection: existing snapshots where aggregates don't match source data
|
||||
-- (catches backfilled imports that arrived after snapshot was calculated)
|
||||
-- 3. Recent recheck: last N days always reprocessed (picks up new orders, corrections)
|
||||
FOR _target_date IN
|
||||
SELECT d FROM (
|
||||
-- Gap fill: find dates with activity but missing snapshots
|
||||
SELECT activity_dates.d
|
||||
FROM (
|
||||
SELECT DISTINCT date::date AS d FROM public.orders
|
||||
WHERE date::date >= _backfill_start AND date::date < CURRENT_DATE - _recent_recheck_days
|
||||
UNION
|
||||
SELECT DISTINCT received_date::date AS d FROM public.receivings
|
||||
WHERE received_date::date >= _backfill_start AND received_date::date < CURRENT_DATE - _recent_recheck_days
|
||||
) activity_dates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.daily_product_snapshots dps WHERE dps.snapshot_date = activity_dates.d
|
||||
)
|
||||
UNION
|
||||
-- Stale detection: compare snapshot aggregates against source tables
|
||||
SELECT snap_agg.snapshot_date AS d
|
||||
FROM (
|
||||
SELECT snapshot_date,
|
||||
COALESCE(SUM(units_received), 0)::bigint AS snap_received,
|
||||
COALESCE(SUM(units_sold), 0)::bigint AS snap_sold
|
||||
FROM public.daily_product_snapshots
|
||||
WHERE snapshot_date >= _backfill_start
|
||||
AND snapshot_date < CURRENT_DATE - _recent_recheck_days
|
||||
GROUP BY snapshot_date
|
||||
) snap_agg
|
||||
LEFT JOIN (
|
||||
SELECT received_date::date AS d, SUM(qty_each)::bigint AS actual_received
|
||||
FROM public.receivings
|
||||
WHERE received_date::date >= _backfill_start
|
||||
AND received_date::date < CURRENT_DATE - _recent_recheck_days
|
||||
GROUP BY received_date::date
|
||||
) recv_agg ON snap_agg.snapshot_date = recv_agg.d
|
||||
LEFT JOIN (
|
||||
SELECT date::date AS d,
|
||||
SUM(CASE WHEN quantity > 0 AND COALESCE(status, 'pending') NOT IN ('canceled', 'returned')
|
||||
THEN quantity ELSE 0 END)::bigint AS actual_sold
|
||||
FROM public.orders
|
||||
WHERE date::date >= _backfill_start
|
||||
AND date::date < CURRENT_DATE - _recent_recheck_days
|
||||
GROUP BY date::date
|
||||
) orders_agg ON snap_agg.snapshot_date = orders_agg.d
|
||||
WHERE snap_agg.snap_received != COALESCE(recv_agg.actual_received, 0)
|
||||
OR snap_agg.snap_sold != COALESCE(orders_agg.actual_sold, 0)
|
||||
UNION
|
||||
-- Recent days: always reprocess
|
||||
SELECT d::date
|
||||
FROM generate_series(
|
||||
(CURRENT_DATE - _recent_recheck_days)::timestamp,
|
||||
CURRENT_DATE::timestamp,
|
||||
'1 day'::interval
|
||||
) d
|
||||
) dates_to_process
|
||||
ORDER BY d
|
||||
LOOP
|
||||
_days_processed := _days_processed + 1;
|
||||
|
||||
-- Process only the days that need updating
|
||||
FOREACH _day_counter IN ARRAY _missing_days LOOP
|
||||
_target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day');
|
||||
RAISE NOTICE 'Processing date: %', _target_date;
|
||||
-- Classify why this date is being processed (for logging)
|
||||
IF _target_date >= CURRENT_DATE - _recent_recheck_days THEN
|
||||
RAISE NOTICE 'Processing date: % [recent recheck]', _target_date;
|
||||
ELSIF NOT EXISTS (SELECT 1 FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) THEN
|
||||
RAISE NOTICE 'Processing date: % [gap fill — no existing snapshot]', _target_date;
|
||||
ELSE
|
||||
RAISE NOTICE 'Processing date: % [stale data — snapshot aggregates mismatch source]', _target_date;
|
||||
END IF;
|
||||
|
||||
-- IMPORTANT: First delete any existing data for this date to prevent duplication
|
||||
DELETE FROM public.daily_product_snapshots
|
||||
@@ -90,7 +124,6 @@ BEGIN
|
||||
COALESCE(
|
||||
o.costeach, -- First use order-specific cost if available
|
||||
get_weighted_avg_cost(p.pid, o.date::date), -- Then use weighted average cost
|
||||
p.landing_cost_price, -- Fallback to landing cost
|
||||
p.cost_price -- Final fallback to current cost
|
||||
) * o.quantity
|
||||
ELSE 0 END), 0.00) AS cogs,
|
||||
@@ -124,14 +157,16 @@ BEGIN
|
||||
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
|
||||
),
|
||||
CurrentStock AS (
|
||||
-- Select current stock values directly from products table
|
||||
-- Use historical stock from stock_snapshots when available,
|
||||
-- falling back to current stock from products table
|
||||
SELECT
|
||||
pid,
|
||||
stock_quantity,
|
||||
COALESCE(landing_cost_price, cost_price, 0.00) as effective_cost_price,
|
||||
COALESCE(price, 0.00) as current_price,
|
||||
COALESCE(regular_price, 0.00) as current_regular_price
|
||||
FROM public.products
|
||||
p.pid,
|
||||
COALESCE(ss.stock_quantity, p.stock_quantity) AS stock_quantity,
|
||||
COALESCE(ss.stock_value, p.stock_quantity * COALESCE(p.cost_price, 0.00)) AS stock_value,
|
||||
COALESCE(p.price, 0.00) AS current_price,
|
||||
COALESCE(p.regular_price, 0.00) AS current_regular_price
|
||||
FROM public.products p
|
||||
LEFT JOIN stock_snapshots ss ON p.pid = ss.pid AND ss.snapshot_date = _target_date
|
||||
),
|
||||
ProductsWithActivity AS (
|
||||
-- Quick pre-filter to only process products with activity
|
||||
@@ -171,7 +206,7 @@ BEGIN
|
||||
COALESCE(sd.sku, p.sku) AS sku, -- Get SKU from sales data or products table
|
||||
-- Inventory Metrics (Using CurrentStock)
|
||||
cs.stock_quantity AS eod_stock_quantity,
|
||||
cs.stock_quantity * cs.effective_cost_price AS eod_stock_cost,
|
||||
cs.stock_value AS eod_stock_cost,
|
||||
cs.stock_quantity * cs.current_price AS eod_stock_retail,
|
||||
cs.stock_quantity * cs.current_regular_price AS eod_stock_gross,
|
||||
(cs.stock_quantity <= 0) AS stockout_flag,
|
||||
@@ -181,10 +216,10 @@ BEGIN
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00),
|
||||
COALESCE(sd.discounts, 0.00),
|
||||
COALESCE(sd.returns_revenue, 0.00),
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
|
||||
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue,
|
||||
COALESCE(sd.cogs, 0.00),
|
||||
COALESCE(sd.gross_regular_revenue, 0.00),
|
||||
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit, -- Basic profit: Net Revenue - COGS
|
||||
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
|
||||
-- Receiving Metrics (From ReceivingData)
|
||||
COALESCE(rd.units_received, 0),
|
||||
COALESCE(rd.cost_received, 0.00),
|
||||
@@ -201,12 +236,18 @@ BEGIN
|
||||
RAISE NOTICE 'Created % daily snapshot records for % with sales/receiving activity', _total_records, _target_date;
|
||||
END LOOP;
|
||||
|
||||
-- Update the status table with the timestamp from the START of this run
|
||||
UPDATE public.calculate_status
|
||||
SET last_calculation_timestamp = _start_time
|
||||
WHERE module_name = _module_name;
|
||||
IF _days_processed = 0 THEN
|
||||
RAISE NOTICE 'No days need updating — all snapshot data is current.';
|
||||
ELSE
|
||||
RAISE NOTICE 'Processed % days total.', _days_processed;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Finished % processing for multiple dates. Duration: %', _module_name, clock_timestamp() - _start_time;
|
||||
-- Update the status table with the timestamp from the START of this run
|
||||
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
|
||||
VALUES (_module_name, _start_time)
|
||||
ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = _start_time;
|
||||
|
||||
RAISE NOTICE 'Finished % script. Duration: %', _module_name, clock_timestamp() - _start_time;
|
||||
|
||||
END $$;
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
-- Description: Populates lifecycle forecast columns on product_metrics from product_forecasts.
|
||||
-- Runs AFTER update_product_metrics.sql so that lead time / days of stock settings are available.
|
||||
-- Dependencies: product_metrics (fully populated), product_forecasts, settings tables.
|
||||
-- Frequency: After each metrics run and/or after forecast engine runs.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
_module_name TEXT := 'lifecycle_forecasts';
|
||||
_start_time TIMESTAMPTZ := clock_timestamp();
|
||||
_updated INT;
|
||||
BEGIN
|
||||
RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time;
|
||||
|
||||
-- Step 1: Set lifecycle_phase from product_forecasts (one phase per product)
|
||||
UPDATE product_metrics pm
|
||||
SET lifecycle_phase = sub.lifecycle_phase
|
||||
FROM (
|
||||
SELECT DISTINCT ON (pid) pid, lifecycle_phase
|
||||
FROM product_forecasts
|
||||
ORDER BY pid, forecast_date
|
||||
) sub
|
||||
WHERE pm.pid = sub.pid
|
||||
AND (pm.lifecycle_phase IS DISTINCT FROM sub.lifecycle_phase);
|
||||
|
||||
GET DIAGNOSTICS _updated = ROW_COUNT;
|
||||
RAISE NOTICE 'Updated lifecycle_phase for % products', _updated;
|
||||
|
||||
-- Step 2: Compute lifecycle-based lead time and planning period forecasts
|
||||
-- Uses each product's configured lead time and days of stock
|
||||
WITH forecast_sums AS (
|
||||
SELECT
|
||||
pf.pid,
|
||||
SUM(pf.forecast_units) FILTER (
|
||||
WHERE pf.forecast_date <= CURRENT_DATE + s.effective_lead_time
|
||||
) AS lt_forecast,
|
||||
SUM(pf.forecast_units) FILTER (
|
||||
WHERE pf.forecast_date <= CURRENT_DATE + s.effective_lead_time + s.effective_days_of_stock
|
||||
) AS pp_forecast
|
||||
FROM product_forecasts pf
|
||||
JOIN (
|
||||
SELECT
|
||||
p.pid,
|
||||
COALESCE(sp.lead_time_days, sv.default_lead_time_days,
|
||||
(SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_lead_time_days'), 14
|
||||
) AS effective_lead_time,
|
||||
COALESCE(sp.days_of_stock, sv.default_days_of_stock,
|
||||
(SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_days_of_stock'), 30
|
||||
) AS effective_days_of_stock
|
||||
FROM products p
|
||||
LEFT JOIN settings_product sp ON p.pid = sp.pid
|
||||
LEFT JOIN settings_vendor sv ON p.vendor = sv.vendor
|
||||
) s ON s.pid = pf.pid
|
||||
WHERE pf.forecast_date >= CURRENT_DATE
|
||||
GROUP BY pf.pid
|
||||
)
|
||||
UPDATE product_metrics pm
|
||||
SET
|
||||
lifecycle_lead_time_forecast = COALESCE(fs.lt_forecast, 0),
|
||||
lifecycle_planning_period_forecast = COALESCE(fs.pp_forecast, 0)
|
||||
FROM forecast_sums fs
|
||||
WHERE pm.pid = fs.pid
|
||||
AND (pm.lifecycle_lead_time_forecast IS DISTINCT FROM COALESCE(fs.lt_forecast, 0)
|
||||
OR pm.lifecycle_planning_period_forecast IS DISTINCT FROM COALESCE(fs.pp_forecast, 0));
|
||||
|
||||
GET DIAGNOSTICS _updated = ROW_COUNT;
|
||||
RAISE NOTICE 'Updated lifecycle forecasts for % products', _updated;
|
||||
|
||||
-- Step 3: Reclassify demand_pattern using residual CV (de-trended)
|
||||
-- For launch/decay products, raw CV is high because of expected lifecycle decay.
|
||||
-- We subtract the expected brand curve value to get residuals, then compute CV on those.
|
||||
-- Products that track their brand curve closely → low residual CV → "stable"
|
||||
-- Products with erratic deviations from curve → higher residual CV → "variable"/"sporadic"
|
||||
WITH product_curve AS (
|
||||
-- Get each product's brand curve and age
|
||||
SELECT
|
||||
pm.pid,
|
||||
pm.lifecycle_phase,
|
||||
pm.date_first_received,
|
||||
blc.amplitude,
|
||||
blc.decay_rate,
|
||||
blc.baseline
|
||||
FROM product_metrics pm
|
||||
JOIN products p ON p.pid = pm.pid
|
||||
LEFT JOIN brand_lifecycle_curves blc
|
||||
ON blc.brand = pm.brand
|
||||
AND blc.root_category IS NULL -- brand-only curve
|
||||
WHERE pm.lifecycle_phase IN ('launch', 'decay')
|
||||
AND pm.date_first_received IS NOT NULL
|
||||
AND blc.amplitude IS NOT NULL
|
||||
),
|
||||
daily_residuals AS (
|
||||
-- Compute residual = actual - expected for each snapshot day
|
||||
-- Curve params are in WEEKLY units; divide by 7 to get daily expected
|
||||
SELECT
|
||||
dps.pid,
|
||||
dps.units_sold,
|
||||
(pc.amplitude * EXP(-pc.decay_rate * (dps.snapshot_date - pc.date_first_received)::numeric / 7.0) + pc.baseline) / 7.0 AS expected,
|
||||
dps.units_sold - (pc.amplitude * EXP(-pc.decay_rate * (dps.snapshot_date - pc.date_first_received)::numeric / 7.0) + pc.baseline) / 7.0 AS residual
|
||||
FROM daily_product_snapshots dps
|
||||
JOIN product_curve pc ON pc.pid = dps.pid
|
||||
WHERE dps.snapshot_date >= CURRENT_DATE - INTERVAL '29 days'
|
||||
AND dps.snapshot_date <= CURRENT_DATE
|
||||
),
|
||||
residual_cv AS (
|
||||
SELECT
|
||||
pid,
|
||||
AVG(units_sold) AS avg_sales,
|
||||
CASE WHEN COUNT(*) >= 7 AND AVG(ABS(expected)) > 0.01 THEN
|
||||
STDDEV_POP(residual) / GREATEST(AVG(ABS(expected)), 0.1)
|
||||
END AS res_cv
|
||||
FROM daily_residuals
|
||||
GROUP BY pid
|
||||
)
|
||||
UPDATE product_metrics pm
|
||||
SET demand_pattern = classify_demand_pattern(rc.avg_sales, rc.res_cv)
|
||||
FROM residual_cv rc
|
||||
WHERE pm.pid = rc.pid
|
||||
AND rc.res_cv IS NOT NULL
|
||||
AND pm.demand_pattern IS DISTINCT FROM classify_demand_pattern(rc.avg_sales, rc.res_cv);
|
||||
|
||||
GET DIAGNOSTICS _updated = ROW_COUNT;
|
||||
RAISE NOTICE 'Reclassified demand_pattern for % launch/decay products', _updated;
|
||||
|
||||
-- Update tracking
|
||||
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
|
||||
VALUES (_module_name, clock_timestamp())
|
||||
ON CONFLICT (module_name) DO UPDATE SET
|
||||
last_calculation_timestamp = EXCLUDED.last_calculation_timestamp;
|
||||
|
||||
RAISE NOTICE '% module complete. Duration: %', _module_name, clock_timestamp() - _start_time;
|
||||
END $$;
|
||||
@@ -21,20 +21,30 @@ BEGIN
|
||||
RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time;
|
||||
|
||||
-- 1. Calculate Average Lead Time
|
||||
-- For each completed PO, find the earliest receiving from the same supplier
|
||||
-- within 180 days, then average those per-PO lead times per product.
|
||||
RAISE NOTICE 'Calculating Average Lead Time...';
|
||||
WITH LeadTimes AS (
|
||||
WITH po_first_receiving AS (
|
||||
SELECT
|
||||
po.pid,
|
||||
-- Calculate lead time by looking at when items ordered on POs were received
|
||||
AVG(GREATEST(1, (r.received_date::date - po.date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days
|
||||
po.po_id,
|
||||
po.date::date AS po_date,
|
||||
MIN(r.received_date::date) AS first_receive_date
|
||||
FROM public.purchase_orders po
|
||||
-- Join to receivings table to find actual receipts
|
||||
JOIN public.receivings r ON r.pid = po.pid
|
||||
WHERE po.status = 'done' -- Only include completed POs
|
||||
AND r.received_date >= po.date -- Ensure received date is not before order date
|
||||
-- Optional: add check to make sure receiving is related to PO if you have source_po_id
|
||||
-- AND (r.source_po_id = po.po_id OR r.source_po_id IS NULL)
|
||||
GROUP BY po.pid
|
||||
JOIN public.receivings r
|
||||
ON r.pid = po.pid
|
||||
AND r.supplier_id = po.supplier_id -- same supplier
|
||||
AND r.received_date >= po.date -- received after order
|
||||
AND r.received_date <= po.date + INTERVAL '180 days' -- within reasonable window
|
||||
WHERE po.status = 'done'
|
||||
GROUP BY po.pid, po.po_id, po.date
|
||||
),
|
||||
LeadTimes AS (
|
||||
SELECT
|
||||
pid,
|
||||
ROUND(AVG(GREATEST(1, first_receive_date - po_date))) AS avg_days
|
||||
FROM po_first_receiving
|
||||
GROUP BY pid
|
||||
)
|
||||
UPDATE public.product_metrics pm
|
||||
SET avg_lead_time_days = lt.avg_days::int
|
||||
|
||||
@@ -52,7 +52,7 @@ BEGIN
|
||||
COALESCE(p.price, 0.00) as current_price,
|
||||
COALESCE(p.regular_price, 0.00) as current_regular_price,
|
||||
COALESCE(p.cost_price, 0.00) as current_cost_price,
|
||||
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as current_effective_cost, -- Use landing if available, else cost
|
||||
COALESCE(p.cost_price, 0.00) as current_effective_cost,
|
||||
p.stock_quantity as current_stock,
|
||||
p.created_at,
|
||||
p.first_received,
|
||||
@@ -61,16 +61,72 @@ BEGIN
|
||||
p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each)
|
||||
FROM public.products p
|
||||
),
|
||||
-- Stale POs: open, >90 days past expected, AND a newer PO exists for the same product.
|
||||
-- These are likely abandoned/superseded and should not consume receivings in FIFO.
|
||||
StalePOLines AS (
|
||||
SELECT po.po_id, po.pid
|
||||
FROM public.purchase_orders po
|
||||
WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent',
|
||||
'electronically_ready_send', 'receiving_started')
|
||||
AND po.expected_date IS NOT NULL
|
||||
AND po.expected_date < _current_date - INTERVAL '90 days'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.purchase_orders newer
|
||||
WHERE newer.pid = po.pid
|
||||
AND newer.status NOT IN ('canceled', 'done')
|
||||
AND COALESCE(newer.date_ordered, newer.date_created)
|
||||
> COALESCE(po.date_ordered, po.date_created)
|
||||
)
|
||||
),
|
||||
-- All non-canceled, non-stale POs in FIFO order per (pid, supplier).
|
||||
-- Includes closed ('done') POs so they consume receivings before open POs.
|
||||
POFifo AS (
|
||||
SELECT
|
||||
po.pid, po.supplier_id, po.po_id, po.ordered, po.status,
|
||||
po.po_cost_price, po.expected_date,
|
||||
SUM(po.ordered) OVER (
|
||||
PARTITION BY po.pid, po.supplier_id
|
||||
ORDER BY COALESCE(po.date_ordered, po.date_created), po.po_id
|
||||
) - po.ordered AS cumulative_before
|
||||
FROM public.purchase_orders po
|
||||
WHERE po.status != 'canceled'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM StalePOLines s
|
||||
WHERE s.po_id = po.po_id AND s.pid = po.pid
|
||||
)
|
||||
),
|
||||
-- Total received per (product, supplier) across all receivings.
|
||||
SupplierReceived AS (
|
||||
SELECT pid, supplier_id, SUM(qty_each) AS total_received
|
||||
FROM public.receivings
|
||||
WHERE status IN ('partial_received', 'full_received', 'paid')
|
||||
GROUP BY pid, supplier_id
|
||||
),
|
||||
-- FIFO allocation: receivings fill oldest POs first per (pid, supplier).
|
||||
-- Only open PO lines are reported; closed POs just absorb receivings.
|
||||
OnOrderInfo AS (
|
||||
SELECT
|
||||
pid,
|
||||
SUM(ordered) AS on_order_qty,
|
||||
SUM(ordered * po_cost_price) AS on_order_cost,
|
||||
MIN(expected_date) AS earliest_expected_date
|
||||
FROM public.purchase_orders
|
||||
WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started')
|
||||
AND status NOT IN ('canceled', 'done')
|
||||
GROUP BY pid
|
||||
po.pid,
|
||||
SUM(GREATEST(0,
|
||||
po.ordered - GREATEST(0, LEAST(po.ordered,
|
||||
COALESCE(sr.total_received, 0) - po.cumulative_before
|
||||
))
|
||||
)) AS on_order_qty,
|
||||
SUM(GREATEST(0,
|
||||
po.ordered - GREATEST(0, LEAST(po.ordered,
|
||||
COALESCE(sr.total_received, 0) - po.cumulative_before
|
||||
))
|
||||
) * po.po_cost_price) AS on_order_cost,
|
||||
MIN(po.expected_date) FILTER (WHERE
|
||||
po.ordered > GREATEST(0, LEAST(po.ordered,
|
||||
COALESCE(sr.total_received, 0) - po.cumulative_before
|
||||
))
|
||||
) AS earliest_expected_date
|
||||
FROM POFifo po
|
||||
LEFT JOIN SupplierReceived sr ON sr.pid = po.pid AND sr.supplier_id = po.supplier_id
|
||||
WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent',
|
||||
'electronically_ready_send', 'receiving_started')
|
||||
GROUP BY po.pid
|
||||
),
|
||||
HistoricalDates AS (
|
||||
-- Note: Calculating these MIN/MAX values hourly can be slow on large tables.
|
||||
@@ -142,6 +198,17 @@ BEGIN
|
||||
FROM public.daily_product_snapshots
|
||||
GROUP BY pid
|
||||
),
|
||||
BeginningStock AS (
|
||||
-- Get stock level from 30 days ago for sell-through calculation.
|
||||
-- Uses the closest available snapshot if exact date is missing (activity-only snapshots).
|
||||
SELECT DISTINCT ON (pid)
|
||||
pid,
|
||||
eod_stock_quantity AS beginning_stock_30d
|
||||
FROM public.daily_product_snapshots
|
||||
WHERE snapshot_date <= _current_date - INTERVAL '30 days'
|
||||
AND snapshot_date >= _current_date - INTERVAL '37 days'
|
||||
ORDER BY pid, snapshot_date DESC
|
||||
),
|
||||
FirstPeriodMetrics AS (
|
||||
SELECT
|
||||
pid,
|
||||
@@ -204,17 +271,30 @@ BEGIN
|
||||
GROUP BY pid
|
||||
),
|
||||
DemandVariability AS (
|
||||
-- Calculate variance and standard deviation of daily sales
|
||||
-- Calculate variance and standard deviation of daily sales over the full 30-day window
|
||||
-- including zero-sales days (not just activity days) for accurate variability metrics.
|
||||
-- Uses algebraic equivalents to avoid expensive CROSS JOIN with generate_series.
|
||||
-- For N=30 total days, k active days, sum S, sum_sq SS:
|
||||
-- mean = S/N, variance = (SS/N) - (S/N)^2 (population variance over all N days)
|
||||
SELECT
|
||||
pid,
|
||||
COUNT(*) AS days_with_data,
|
||||
AVG(units_sold) AS avg_daily_sales,
|
||||
VARIANCE(units_sold) AS sales_variance,
|
||||
STDDEV(units_sold) AS sales_std_dev,
|
||||
-- Coefficient of variation
|
||||
CASE
|
||||
WHEN AVG(units_sold) > 0 THEN STDDEV(units_sold) / AVG(units_sold)
|
||||
ELSE NULL
|
||||
SUM(units_sold)::numeric / 30.0 AS avg_daily_sales,
|
||||
CASE WHEN SUM(units_sold) > 0 THEN
|
||||
(SUM(units_sold::numeric * units_sold::numeric) / 30.0)
|
||||
- (SUM(units_sold)::numeric / 30.0) * (SUM(units_sold)::numeric / 30.0)
|
||||
END AS sales_variance,
|
||||
CASE WHEN SUM(units_sold) > 0 THEN
|
||||
(|/ GREATEST(0,
|
||||
(SUM(units_sold::numeric * units_sold::numeric) / 30.0)
|
||||
- (SUM(units_sold)::numeric / 30.0) * (SUM(units_sold)::numeric / 30.0)
|
||||
))::numeric
|
||||
END AS sales_std_dev,
|
||||
CASE WHEN SUM(units_sold) > 0 THEN
|
||||
((|/ GREATEST(0,
|
||||
(SUM(units_sold::numeric * units_sold::numeric) / 30.0)
|
||||
- (SUM(units_sold)::numeric / 30.0) * (SUM(units_sold)::numeric / 30.0)
|
||||
)) / (SUM(units_sold)::numeric / 30.0))::numeric
|
||||
END AS sales_cv
|
||||
FROM public.daily_product_snapshots
|
||||
WHERE snapshot_date >= _current_date - INTERVAL '29 days'
|
||||
@@ -242,14 +322,51 @@ BEGIN
|
||||
GROUP BY pid
|
||||
),
|
||||
SeasonalityAnalysis AS (
|
||||
-- Simple seasonality detection
|
||||
-- Set-based seasonality detection (replaces per-product function calls)
|
||||
-- Computes monthly CV and peak-to-average ratio across the last 12 months
|
||||
SELECT
|
||||
p.pid,
|
||||
sp.seasonal_pattern,
|
||||
sp.seasonality_index,
|
||||
sp.peak_season
|
||||
FROM products p
|
||||
CROSS JOIN LATERAL detect_seasonal_pattern(p.pid) sp
|
||||
pid,
|
||||
CASE
|
||||
WHEN monthly_cv > 0.5 AND seasonality_index > 150 THEN 'strong'
|
||||
WHEN monthly_cv > 0.3 AND seasonality_index > 120 THEN 'moderate'
|
||||
ELSE 'none'
|
||||
END::varchar AS seasonal_pattern,
|
||||
CASE
|
||||
WHEN monthly_cv > 0.3 AND seasonality_index > 120 THEN seasonality_index
|
||||
ELSE 100::numeric
|
||||
END AS seasonality_index,
|
||||
CASE
|
||||
WHEN monthly_cv > 0.3 AND seasonality_index > 120
|
||||
THEN TRIM(TO_CHAR(TO_DATE(peak_month::text, 'MM'), 'Month'))
|
||||
ELSE NULL
|
||||
END::varchar AS peak_season
|
||||
FROM (
|
||||
SELECT
|
||||
pid,
|
||||
CASE WHEN overall_avg > 0 AND monthly_stddev IS NOT NULL
|
||||
THEN monthly_stddev / overall_avg ELSE 0 END AS monthly_cv,
|
||||
CASE WHEN overall_avg > 0
|
||||
THEN ROUND((max_month_avg / overall_avg * 100)::numeric, 2)
|
||||
ELSE 100 END AS seasonality_index,
|
||||
peak_month
|
||||
FROM (
|
||||
SELECT
|
||||
ms.pid,
|
||||
AVG(ms.month_avg) AS overall_avg,
|
||||
STDDEV(ms.month_avg) AS monthly_stddev,
|
||||
MAX(ms.month_avg) AS max_month_avg,
|
||||
(ARRAY_AGG(ms.mo ORDER BY ms.month_avg DESC))[1] AS peak_month
|
||||
FROM (
|
||||
SELECT pid, EXTRACT(MONTH FROM snapshot_date)::int AS mo, AVG(units_sold) AS month_avg
|
||||
FROM daily_product_snapshots
|
||||
WHERE snapshot_date >= CURRENT_DATE - INTERVAL '365 days'
|
||||
AND units_sold > 0
|
||||
GROUP BY pid, EXTRACT(MONTH FROM snapshot_date)
|
||||
) ms
|
||||
GROUP BY ms.pid
|
||||
HAVING COUNT(*) >= 3 -- Need at least 3 months for meaningful seasonality
|
||||
) agg
|
||||
) classified
|
||||
)
|
||||
-- Final UPSERT into product_metrics
|
||||
INSERT INTO public.product_metrics (
|
||||
@@ -257,7 +374,7 @@ BEGIN
|
||||
barcode, harmonized_tariff_code, vendor_reference, notions_reference, line, subline, artist,
|
||||
moq, rating, reviews, weight, length, width, height, country_of_origin, location,
|
||||
baskets, notifies, preorder_count, notions_inv_count,
|
||||
current_price, current_regular_price, current_cost_price, current_landing_cost_price,
|
||||
current_price, current_regular_price, current_cost_price,
|
||||
current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
|
||||
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
|
||||
date_created, date_first_received, date_last_received, date_first_sold, date_last_sold, age_days,
|
||||
@@ -295,7 +412,7 @@ BEGIN
|
||||
ci.barcode, ci.harmonized_tariff_code, ci.vendor_reference, ci.notions_reference, ci.line, ci.subline, ci.artist,
|
||||
ci.moq, ci.rating, ci.reviews, ci.weight, ci.length, ci.width, ci.height, ci.country_of_origin, ci.location,
|
||||
ci.baskets, ci.notifies, ci.preorder_count, ci.notions_inv_count,
|
||||
ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost,
|
||||
ci.current_price, ci.current_regular_price, ci.current_cost_price,
|
||||
ci.current_stock, ci.current_stock * ci.current_effective_cost, ci.current_stock * ci.current_price, ci.current_stock * ci.current_regular_price,
|
||||
COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00), COALESCE(ooi.on_order_qty, 0) * ci.current_price, ooi.earliest_expected_date,
|
||||
ci.created_at::date, COALESCE(ci.first_received::date, hd.date_first_received_calc), hd.date_last_received_calc, hd.date_first_sold, COALESCE(ci.date_last_sold, hd.max_order_date),
|
||||
@@ -321,9 +438,9 @@ BEGIN
|
||||
(GREATEST(0, ci.historical_total_sold - COALESCE(lr.lifetime_units_from_orders, 0)) *
|
||||
COALESCE(
|
||||
-- Use oldest known price from snapshots as proxy
|
||||
(SELECT revenue_7d / NULLIF(sales_7d, 0)
|
||||
(SELECT net_revenue / NULLIF(units_sold, 0)
|
||||
FROM daily_product_snapshots
|
||||
WHERE pid = ci.pid AND sales_7d > 0
|
||||
WHERE pid = ci.pid AND units_sold > 0
|
||||
ORDER BY snapshot_date ASC
|
||||
LIMIT 1),
|
||||
ci.current_price
|
||||
@@ -353,10 +470,10 @@ BEGIN
|
||||
(sa.stockout_days_30d / 30.0) * 100 AS stockout_rate_30d,
|
||||
sa.gross_regular_revenue_30d - sa.gross_revenue_30d AS markdown_30d,
|
||||
((sa.gross_regular_revenue_30d - sa.gross_revenue_30d) / NULLIF(sa.gross_regular_revenue_30d, 0)) * 100 AS markdown_rate_30d,
|
||||
-- Fix sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received)
|
||||
-- Approximating beginning inventory as current stock + units sold - units received
|
||||
-- Sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received)
|
||||
-- Uses actual snapshot from 30 days ago as beginning stock, falls back to avg_stock_units_30d
|
||||
(sa.sales_30d / NULLIF(
|
||||
ci.current_stock + sa.sales_30d + sa.returns_units_30d - sa.received_qty_30d,
|
||||
COALESCE(bs.beginning_stock_30d, sa.avg_stock_units_30d::int, 0) + sa.received_qty_30d,
|
||||
0
|
||||
)) * 100 AS sell_through_30d,
|
||||
|
||||
@@ -505,6 +622,7 @@ BEGIN
|
||||
LEFT JOIN PreviousPeriodMetrics ppm ON ci.pid = ppm.pid
|
||||
LEFT JOIN DemandVariability dv ON ci.pid = dv.pid
|
||||
LEFT JOIN ServiceLevels sl ON ci.pid = sl.pid
|
||||
LEFT JOIN BeginningStock bs ON ci.pid = bs.pid
|
||||
LEFT JOIN SeasonalityAnalysis season ON ci.pid = season.pid
|
||||
WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL -- Exclude products explicitly marked
|
||||
|
||||
@@ -514,7 +632,7 @@ BEGIN
|
||||
barcode = EXCLUDED.barcode, harmonized_tariff_code = EXCLUDED.harmonized_tariff_code, vendor_reference = EXCLUDED.vendor_reference, notions_reference = EXCLUDED.notions_reference, line = EXCLUDED.line, subline = EXCLUDED.subline, artist = EXCLUDED.artist,
|
||||
moq = EXCLUDED.moq, rating = EXCLUDED.rating, reviews = EXCLUDED.reviews, weight = EXCLUDED.weight, length = EXCLUDED.length, width = EXCLUDED.width, height = EXCLUDED.height, country_of_origin = EXCLUDED.country_of_origin, location = EXCLUDED.location,
|
||||
baskets = EXCLUDED.baskets, notifies = EXCLUDED.notifies, preorder_count = EXCLUDED.preorder_count, notions_inv_count = EXCLUDED.notions_inv_count,
|
||||
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price,
|
||||
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price,
|
||||
current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross,
|
||||
on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date,
|
||||
date_created = EXCLUDED.date_created, date_first_received = EXCLUDED.date_first_received, date_last_received = EXCLUDED.date_last_received, date_first_sold = EXCLUDED.date_first_sold, date_last_sold = EXCLUDED.date_last_sold, age_days = EXCLUDED.age_days,
|
||||
@@ -567,11 +685,26 @@ BEGIN
|
||||
product_metrics.replenishment_units IS DISTINCT FROM EXCLUDED.replenishment_units OR
|
||||
product_metrics.stock_cover_in_days IS DISTINCT FROM EXCLUDED.stock_cover_in_days OR
|
||||
product_metrics.yesterday_sales IS DISTINCT FROM EXCLUDED.yesterday_sales OR
|
||||
-- Check a few other important fields that might change
|
||||
product_metrics.date_last_sold IS DISTINCT FROM EXCLUDED.date_last_sold OR
|
||||
product_metrics.earliest_expected_date IS DISTINCT FROM EXCLUDED.earliest_expected_date OR
|
||||
product_metrics.lifetime_sales IS DISTINCT FROM EXCLUDED.lifetime_sales OR
|
||||
product_metrics.lifetime_revenue_quality IS DISTINCT FROM EXCLUDED.lifetime_revenue_quality
|
||||
product_metrics.lifetime_revenue_quality IS DISTINCT FROM EXCLUDED.lifetime_revenue_quality OR
|
||||
-- Derived metrics that can change even when source fields don't
|
||||
product_metrics.profit_30d IS DISTINCT FROM EXCLUDED.profit_30d OR
|
||||
product_metrics.cogs_30d IS DISTINCT FROM EXCLUDED.cogs_30d OR
|
||||
product_metrics.margin_30d IS DISTINCT FROM EXCLUDED.margin_30d OR
|
||||
product_metrics.stockout_days_30d IS DISTINCT FROM EXCLUDED.stockout_days_30d OR
|
||||
product_metrics.sell_through_30d IS DISTINCT FROM EXCLUDED.sell_through_30d OR
|
||||
-- Growth and variability metrics
|
||||
product_metrics.sales_growth_30d_vs_prev IS DISTINCT FROM EXCLUDED.sales_growth_30d_vs_prev OR
|
||||
product_metrics.revenue_growth_30d_vs_prev IS DISTINCT FROM EXCLUDED.revenue_growth_30d_vs_prev OR
|
||||
product_metrics.demand_pattern IS DISTINCT FROM EXCLUDED.demand_pattern OR
|
||||
product_metrics.seasonal_pattern IS DISTINCT FROM EXCLUDED.seasonal_pattern OR
|
||||
product_metrics.seasonality_index IS DISTINCT FROM EXCLUDED.seasonality_index OR
|
||||
product_metrics.service_level_30d IS DISTINCT FROM EXCLUDED.service_level_30d OR
|
||||
product_metrics.fill_rate_30d IS DISTINCT FROM EXCLUDED.fill_rate_30d OR
|
||||
-- Time-based safety net: always update if more than 1 day stale
|
||||
product_metrics.last_calculated < NOW() - INTERVAL '1 day'
|
||||
;
|
||||
|
||||
-- Update the status table with the timestamp from the START of this run
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
724
inventory-server/src/routes/newsletter.js
Normal file
724
inventory-server/src/routes/newsletter.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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) => {
|
||||
|
||||
6
inventory/package-lock.json
generated
6
inventory/package-lock.json
generated
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
|
||||
234
inventory/src/components/ai/AiDescriptionCompare.tsx
Normal file
234
inventory/src/components/ai/AiDescriptionCompare.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
inventory/src/components/analytics/AgingSellThrough.tsx
Normal file
143
inventory/src/components/analytics/AgingSellThrough.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface AgingCohort {
|
||||
cohort: string;
|
||||
productCount: number;
|
||||
avgSellThrough: number;
|
||||
stockCost: number;
|
||||
revenue: number;
|
||||
unitsSold: number;
|
||||
}
|
||||
|
||||
function getSellThroughColor(rate: number): string {
|
||||
if (rate >= 30) return METRIC_COLORS.revenue;
|
||||
if (rate >= 15) return METRIC_COLORS.orders;
|
||||
if (rate >= 5) return METRIC_COLORS.comparison;
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
export function AgingSellThrough() {
|
||||
const { data, isLoading, isError } = useQuery<AgingCohort[]>({
|
||||
queryKey: ['aging-sell-through'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/aging`);
|
||||
if (!response.ok) throw new Error('Failed to fetch aging data');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Aging & Sell-Through</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load aging data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Aging & Sell-Through</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading aging data...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sell-Through Rate by Age</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Avg 30-day sell-through % for products by age since first received
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="cohort" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as AgingCohort;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">Age: {d.cohort}</p>
|
||||
<p>Sell-through: <span className="font-medium">{d.avgSellThrough}%</span></p>
|
||||
<p>{d.productCount.toLocaleString()} products</p>
|
||||
<p>Stock value: {formatCurrency(d.stockCost)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="avgSellThrough" name="Sell-Through %" radius={[4, 4, 0, 0]}>
|
||||
{data.map((entry, i) => (
|
||||
<Cell key={i} fill={getSellThroughColor(entry.avgSellThrough)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Capital Tied Up by Age</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stock investment distribution across product age cohorts
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="cohort" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={formatCurrency} tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as AgingCohort;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">Age: {d.cohort}</p>
|
||||
<p>Stock cost: {formatCurrency(d.stockCost)}</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
|
||||
<p>{d.productCount.toLocaleString()} products</p>
|
||||
<p>{d.unitsSold.toLocaleString()} units sold (30d)</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="stockCost" name="Stock Investment" fill={METRIC_COLORS.aov} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
inventory/src/components/analytics/CapitalEfficiency.tsx
Normal file
201
inventory/src/components/analytics/CapitalEfficiency.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
ZAxis,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface BrandData {
|
||||
brand: string;
|
||||
productCount: number;
|
||||
stockCost: number;
|
||||
profit30d: number;
|
||||
revenue30d: number;
|
||||
gmroi: number;
|
||||
}
|
||||
|
||||
interface EfficiencyData {
|
||||
brands: BrandData[];
|
||||
}
|
||||
|
||||
function getGmroiColor(gmroi: number): string {
|
||||
if (gmroi >= 3) return METRIC_COLORS.revenue; // emerald — strong
|
||||
if (gmroi >= 1) return METRIC_COLORS.comparison; // amber — acceptable
|
||||
return '#ef4444'; // red — poor
|
||||
}
|
||||
|
||||
type GmroiView = 'top' | 'bottom';
|
||||
|
||||
export function CapitalEfficiency() {
|
||||
const [gmroiView, setGmroiView] = useState<GmroiView>('top');
|
||||
const { data, isLoading, isError } = useQuery<EfficiencyData>({
|
||||
queryKey: ['capital-efficiency'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/efficiency`);
|
||||
if (!response.ok) throw new Error('Failed to fetch capital efficiency');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Capital Efficiency</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load efficiency data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Capital Efficiency</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading efficiency...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Top or bottom 15 by GMROI for bar chart
|
||||
const sortedGmroi = gmroiView === 'top'
|
||||
? [...data.brands].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15)
|
||||
: [...data.brands].sort((a, b) => a.gmroi - b.gmroi).slice(0, 15);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>GMROI by Brand</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Annualized gross margin return on investment (top 30 brands by stock value)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(['top', 'bottom'] as GmroiView[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setGmroiView(v)}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
gmroiView === v
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
{v === 'top' ? 'Best' : 'Worst'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={sortedGmroi} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="brand"
|
||||
width={140}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as BrandData;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">{d.brand}</p>
|
||||
<p>GMROI: <span className="font-medium">{d.gmroi.toFixed(2)}</span></p>
|
||||
<p>Stock Investment: {formatCurrency(d.stockCost)}</p>
|
||||
<p>Profit (30d): {formatCurrency(d.profit30d)}</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
|
||||
<p>{d.productCount} products</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine x={3} stroke="#9ca3af" strokeDasharray="3 3" label={{ value: '3.0', position: 'top', fontSize: 10 }} />
|
||||
<Bar dataKey="gmroi" name="GMROI" radius={[0, 4, 4, 0]}>
|
||||
{sortedGmroi.map((entry, i) => (
|
||||
<Cell key={i} fill={getGmroiColor(entry.gmroi)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Investment vs Profit by Brand</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bubble size = product count. Ideal: high profit, low stock cost.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<ScatterChart>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="stockCost"
|
||||
name="Stock Investment"
|
||||
tickFormatter={formatCurrency}
|
||||
tick={{ fontSize: 11 }}
|
||||
type="number"
|
||||
label={{ value: 'Stock Investment', position: 'insideBottom', offset: -5, fontSize: 12, fill: '#888' }}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="profit30d"
|
||||
name="Profit (30d)"
|
||||
tickFormatter={formatCurrency}
|
||||
tick={{ fontSize: 11 }}
|
||||
type="number"
|
||||
label={{ value: 'Profit (30d)', angle: -90, position: 'insideLeft', offset: 10, fontSize: 12, fill: '#888' }}
|
||||
/>
|
||||
<ZAxis dataKey="productCount" range={[40, 400]} name="Products" />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as BrandData;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">{d.brand}</p>
|
||||
<p>Stock Investment: {formatCurrency(d.stockCost)}</p>
|
||||
<p>Profit (30d): {formatCurrency(d.profit30d)}</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
|
||||
<p>{d.productCount} products</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Scatter data={data.brands} fill={METRIC_COLORS.orders} fillOpacity={0.6} />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, PieChart, Pie, Cell, Legend } from 'recharts';
|
||||
import config from '../../config';
|
||||
|
||||
interface CategoryData {
|
||||
performance: {
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
revenue: number;
|
||||
profit: number;
|
||||
growth: number;
|
||||
productCount: number;
|
||||
}[];
|
||||
distribution: {
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
value: number;
|
||||
}[];
|
||||
trends: {
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
month: string;
|
||||
sales: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
const COLORS = ['#4ade80', '#60a5fa', '#f87171', '#fbbf24', '#a78bfa', '#f472b6'];
|
||||
|
||||
export function CategoryPerformance() {
|
||||
const { data, isLoading } = useQuery<CategoryData>({
|
||||
queryKey: ['category-performance'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/categories`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch category performance');
|
||||
}
|
||||
const rawData = await response.json();
|
||||
return {
|
||||
performance: rawData.performance.map((item: any) => ({
|
||||
category: item.category || '',
|
||||
categoryPath: item.categoryPath || item.categorypath || item.category || '',
|
||||
revenue: Number(item.revenue) || 0,
|
||||
profit: Number(item.profit) || 0,
|
||||
growth: Number(item.growth) || 0,
|
||||
productCount: Number(item.productCount) || Number(item.productcount) || 0
|
||||
})),
|
||||
distribution: rawData.distribution.map((item: any) => ({
|
||||
category: item.category || '',
|
||||
categoryPath: item.categoryPath || item.categorypath || item.category || '',
|
||||
value: Number(item.value) || 0
|
||||
})),
|
||||
trends: rawData.trends.map((item: any) => ({
|
||||
category: item.category || '',
|
||||
categoryPath: item.categoryPath || item.categorypath || item.category || '',
|
||||
month: item.month || '',
|
||||
sales: Number(item.sales) || 0
|
||||
}))
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <div>Loading category performance...</div>;
|
||||
}
|
||||
|
||||
const formatGrowth = (growth: number) => {
|
||||
const value = growth >= 0 ? `+${growth.toFixed(1)}%` : `${growth.toFixed(1)}%`;
|
||||
const color = growth >= 0 ? 'text-green-500' : 'text-red-500';
|
||||
return <span className={color}>{value}</span>;
|
||||
};
|
||||
|
||||
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category Revenue Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.distribution}
|
||||
dataKey="value"
|
||||
nameKey="categoryPath"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
label={({ categoryPath }) => getShortCategoryName(categoryPath)}
|
||||
>
|
||||
{data.distribution.map((entry, index) => (
|
||||
<Cell
|
||||
key={`${entry.category}-${entry.value}-${index}`}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number, _: string, props: any) => [
|
||||
`$${value.toLocaleString()}`,
|
||||
<div key="tooltip">
|
||||
<div className="font-medium">Category Path:</div>
|
||||
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
|
||||
<div className="mt-1">Revenue</div>
|
||||
</div>
|
||||
]}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value) => getShortCategoryName(value)}
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category Growth Rates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data.performance}>
|
||||
<XAxis
|
||||
dataKey="categoryPath"
|
||||
tick={({ x, y, payload }) => (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
dy={16}
|
||||
textAnchor="end"
|
||||
fill="#888888"
|
||||
transform="rotate(-35)"
|
||||
>
|
||||
{getShortCategoryName(payload.value)}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||
<Tooltip
|
||||
formatter={(value: number, _: string, props: any) => [
|
||||
`${value.toFixed(1)}%`,
|
||||
<div key="tooltip">
|
||||
<div className="font-medium">Category Path:</div>
|
||||
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
|
||||
<div className="mt-1">Growth Rate</div>
|
||||
</div>
|
||||
]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="growth"
|
||||
fill="#4ade80"
|
||||
name="Growth Rate"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category Performance Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{data.performance.map((category) => (
|
||||
<div key={`${category.category}-${category.revenue}`} className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{getShortCategoryName(category.categoryPath)}</p>
|
||||
<p className="text-xs text-muted-foreground">{category.categoryPath}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{category.productCount} products
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 text-right space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
${category.revenue.toLocaleString()} revenue
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
${category.profit.toLocaleString()} profit
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Growth: {formatGrowth(category.growth)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
inventory/src/components/analytics/DiscountImpact.tsx
Normal file
159
inventory/src/components/analytics/DiscountImpact.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface DiscountRow {
|
||||
abcClass: string;
|
||||
discountBucket: string;
|
||||
productCount: number;
|
||||
avgSellThrough: number;
|
||||
revenue: number;
|
||||
discountAmount: number;
|
||||
profit: number;
|
||||
}
|
||||
|
||||
const CLASS_COLORS: Record<string, string> = {
|
||||
A: METRIC_COLORS.revenue,
|
||||
B: METRIC_COLORS.orders,
|
||||
C: METRIC_COLORS.comparison,
|
||||
};
|
||||
|
||||
export function DiscountImpact() {
|
||||
const { data, isLoading, isError } = useQuery<DiscountRow[]>({
|
||||
queryKey: ['discount-impact'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/discounts`);
|
||||
if (!response.ok) throw new Error('Failed to fetch discount data');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Discount Impact</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load discount data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Discount Impact</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading discount data...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Pivot: for each discount bucket, show avg sell-through by ABC class
|
||||
const buckets = ['No Discount', '1-10%', '11-20%', '21-30%', '30%+'];
|
||||
const chartData = buckets.map(bucket => {
|
||||
const row: Record<string, string | number> = { bucket };
|
||||
['A', 'B', 'C'].forEach(cls => {
|
||||
const match = data.find(d => d.discountBucket === bucket && d.abcClass === cls);
|
||||
row[`Class ${cls}`] = match?.avgSellThrough || 0;
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
// Summary by ABC class
|
||||
const classSummary = ['A', 'B', 'C'].map(cls => {
|
||||
const rows = data.filter(d => d.abcClass === cls);
|
||||
return {
|
||||
abcClass: cls,
|
||||
totalProducts: rows.reduce((s, r) => s + r.productCount, 0),
|
||||
totalDiscounts: rows.reduce((s, r) => s + r.discountAmount, 0),
|
||||
totalRevenue: rows.reduce((s, r) => s + r.revenue, 0),
|
||||
totalProfit: rows.reduce((s, r) => s + r.profit, 0),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sell-Through by Discount Level</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Avg 30-day sell-through % at each discount bracket, by ABC class
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="bucket" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(value: number) => [`${value}%`]} />
|
||||
<Legend />
|
||||
<Bar dataKey="Class A" fill={CLASS_COLORS.A} radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="Class B" fill={CLASS_COLORS.B} radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="Class C" fill={CLASS_COLORS.C} radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Discount Leakage by Class</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How much discount is given relative to revenue per ABC class
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-2 text-left font-medium">Class</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Products</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Revenue</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Discounts</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Disc %</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Profit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classSummary.map((row) => (
|
||||
<tr key={row.abcClass} className="border-b">
|
||||
<td className="px-4 py-2 font-medium">Class {row.abcClass}</td>
|
||||
<td className="px-4 py-2 text-right">{row.totalProducts.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(row.totalRevenue)}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(row.totalDiscounts)}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{row.totalRevenue > 0
|
||||
? ((row.totalDiscounts / (row.totalRevenue + row.totalDiscounts)) * 100).toFixed(1)
|
||||
: '0'}%
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(row.totalProfit)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
inventory/src/components/analytics/GrowthMomentum.tsx
Normal file
217
inventory/src/components/analytics/GrowthMomentum.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { TrendingUp, TrendingDown, Plus, Archive } from 'lucide-react';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface GrowthRow {
|
||||
abcClass: string;
|
||||
growthBucket: string;
|
||||
productCount: number;
|
||||
revenue: number;
|
||||
stockCost: number;
|
||||
}
|
||||
|
||||
interface GrowthSummary {
|
||||
comparableCount: number;
|
||||
growingCount: number;
|
||||
decliningCount: number;
|
||||
weightedAvgGrowth: number;
|
||||
medianGrowth: number;
|
||||
}
|
||||
|
||||
interface CatalogTurnover {
|
||||
newProducts: number;
|
||||
newProductRevenue: number;
|
||||
discontinued: number;
|
||||
discontinuedStockValue: number;
|
||||
}
|
||||
|
||||
interface GrowthData {
|
||||
byClass: GrowthRow[];
|
||||
summary: GrowthSummary;
|
||||
turnover: CatalogTurnover;
|
||||
}
|
||||
|
||||
const GROWTH_COLORS: Record<string, string> = {
|
||||
'Strong Growth (>50%)': METRIC_COLORS.revenue,
|
||||
'Growing (0-50%)': '#34d399',
|
||||
'Declining (0-50%)': METRIC_COLORS.comparison,
|
||||
'Sharp Decline (>50%)': '#ef4444',
|
||||
};
|
||||
|
||||
export function GrowthMomentum() {
|
||||
const { data, isLoading, isError } = useQuery<GrowthData>({
|
||||
queryKey: ['growth-momentum'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/growth`);
|
||||
if (!response.ok) throw new Error('Failed to fetch growth data');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>YoY Growth Momentum</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load growth data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>YoY Growth Momentum</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading growth data...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, turnover } = data;
|
||||
const growingPct = summary.comparableCount > 0
|
||||
? ((summary.growingCount / summary.comparableCount) * 100).toFixed(0)
|
||||
: '0';
|
||||
|
||||
// Pivot: for each ABC class, show product counts by growth bucket
|
||||
const classes = ['A', 'B', 'C'];
|
||||
const buckets = ['Strong Growth (>50%)', 'Growing (0-50%)', 'Declining (0-50%)', 'Sharp Decline (>50%)'];
|
||||
const chartData = classes.map(cls => {
|
||||
const row: Record<string, string | number> = { abcClass: `Class ${cls}` };
|
||||
buckets.forEach(bucket => {
|
||||
const match = data.byClass.find(d => d.abcClass === cls && d.growthBucket === bucket);
|
||||
row[bucket] = match?.productCount || 0;
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Row 1: Comparable growth metrics */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-green-500/10">
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Comparable Growing</p>
|
||||
<p className="text-xl font-bold">{growingPct}%</p>
|
||||
<p className="text-xs text-muted-foreground">{summary.growingCount.toLocaleString()} of {summary.comparableCount.toLocaleString()} products</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-red-500/10">
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Comparable Declining</p>
|
||||
<p className="text-xl font-bold">{summary.decliningCount.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">products with lower YoY sales</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">Weighted Avg Growth</p>
|
||||
<p className={`text-2xl font-bold ${summary.weightedAvgGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{summary.weightedAvgGrowth > 0 ? '+' : ''}{summary.weightedAvgGrowth}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">revenue-weighted</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">Median YoY Growth</p>
|
||||
<p className={`text-2xl font-bold ${summary.medianGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{summary.medianGrowth > 0 ? '+' : ''}{summary.medianGrowth}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">typical product growth</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Catalog turnover */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-blue-500/10">
|
||||
<Plus className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">New Products (<1yr)</p>
|
||||
<p className="text-xl font-bold">{turnover.newProducts.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatCurrency(turnover.newProductRevenue)} revenue (30d)</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-amber-500/10">
|
||||
<Archive className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Discontinued</p>
|
||||
<p className="text-xl font-bold">{turnover.discontinued.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{turnover.discontinuedStockValue > 0
|
||||
? `${formatCurrency(turnover.discontinuedStockValue)} still in stock`
|
||||
: 'no remaining stock'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Chart: comparable products only */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Comparable Growth by ABC Class</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Products selling in both this and last year's period — excludes new launches and discontinued
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="abcClass" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} label={{ value: 'Products', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }} />
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{buckets.map(bucket => (
|
||||
<Bar
|
||||
key={bucket}
|
||||
dataKey={bucket}
|
||||
stackId="growth"
|
||||
fill={GROWTH_COLORS[bucket]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
inventory/src/components/analytics/InventoryFlow.tsx
Normal file
186
inventory/src/components/analytics/InventoryFlow.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
import { ArrowDownToLine, ArrowUpFromLine, TrendingUp } from 'lucide-react';
|
||||
|
||||
interface FlowPoint {
|
||||
date: string;
|
||||
unitsReceived: number;
|
||||
costReceived: number;
|
||||
unitsSold: number;
|
||||
cogsSold: number;
|
||||
}
|
||||
|
||||
type Period = 30 | 90;
|
||||
|
||||
function formatDate(dateStr: string, period: Period): string {
|
||||
const d = new Date(dateStr);
|
||||
if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function InventoryFlow() {
|
||||
const [period, setPeriod] = useState<Period>(30);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<FlowPoint[]>({
|
||||
queryKey: ['inventory-flow', period],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/flow?period=${period}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch inventory flow');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
const totals = useMemo(() => {
|
||||
if (!data) return { received: 0, sold: 0, net: 0 };
|
||||
const received = data.reduce((s, d) => s + d.costReceived, 0);
|
||||
const sold = data.reduce((s, d) => s + d.cogsSold, 0);
|
||||
return { received, sold, net: received - sold };
|
||||
}, [data]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
netFlow: d.costReceived - d.cogsSold,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Inventory Flow: Receiving vs Selling</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Daily cost of goods received vs cost of goods sold
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{([30, 90] as Period[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
period === p
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
{`${p}D`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isError ? (
|
||||
<div className="h-[400px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load flow data</p>
|
||||
</div>
|
||||
) : isLoading || !data ? (
|
||||
<div className="h-[400px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading flow data...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Summary stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div className="rounded-full p-2 bg-green-500/10">
|
||||
<ArrowDownToLine className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Received</p>
|
||||
<p className="text-lg font-bold">{formatCurrency(totals.received)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div className="rounded-full p-2 bg-blue-500/10">
|
||||
<ArrowUpFromLine className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Sold (COGS)</p>
|
||||
<p className="text-lg font-bold">{formatCurrency(totals.sold)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div className={`rounded-full p-2 ${totals.net >= 0 ? 'bg-amber-500/10' : 'bg-green-500/10'}`}>
|
||||
<TrendingUp className={`h-4 w-4 ${totals.net >= 0 ? 'text-amber-500' : 'text-green-500'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Net Change</p>
|
||||
<p className={`text-lg font-bold ${totals.net >= 0 ? 'text-amber-600' : 'text-green-600'}`}>
|
||||
{totals.net >= 0 ? '+' : ''}{formatCurrency(totals.net)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totals.net >= 0 ? 'inventory growing' : 'inventory shrinking'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ComposedChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v) => formatDate(v, period)}
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={period === 90 ? 6 : 2}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatCurrency}
|
||||
tick={{ fontSize: 12 }}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(v) => new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
formatter={(value: number, name: string) => [formatCurrency(value), name]}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="costReceived"
|
||||
fill={METRIC_COLORS.revenue}
|
||||
name="Received (Cost)"
|
||||
radius={[2, 2, 0, 0]}
|
||||
opacity={0.7}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="cogsSold"
|
||||
fill={METRIC_COLORS.orders}
|
||||
name="Sold (COGS)"
|
||||
radius={[2, 2, 0, 0]}
|
||||
opacity={0.7}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="netFlow"
|
||||
stroke={METRIC_COLORS.comparison}
|
||||
name="Net Flow"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
131
inventory/src/components/analytics/InventoryTrends.tsx
Normal file
131
inventory/src/components/analytics/InventoryTrends.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
ComposedChart,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
|
||||
interface TrendPoint {
|
||||
date: string;
|
||||
stockoutCount: number;
|
||||
unitsSold: number;
|
||||
}
|
||||
|
||||
type Period = 30 | 90 | 365;
|
||||
|
||||
function formatDate(dateStr: string, period: Period): string {
|
||||
const d = new Date(dateStr);
|
||||
if (period === 365) return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||
if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function InventoryTrends() {
|
||||
const [period, setPeriod] = useState<Period>(90);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<TrendPoint[]>({
|
||||
queryKey: ['inventory-trends', period],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/inventory-trends?period=${period}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch inventory trends');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Daily Sales Activity & Stockouts</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Units sold per day with stockout product count overlay
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{([30, 90, 365] as Period[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
period === p
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
{p === 365 ? '1Y' : `${p}D`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isError ? (
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load trends data</p>
|
||||
</div>
|
||||
) : isLoading || !data ? (
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading trends...</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<ComposedChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v) => formatDate(v, period)}
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={period === 365 ? 29 : period === 90 ? 6 : 2}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fontSize: 12 }}
|
||||
width={60}
|
||||
label={{ value: 'Units Sold', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 12 }}
|
||||
width={60}
|
||||
label={{ value: 'Stockouts', angle: 90, position: 'insideRight', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(v) => new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
|
||||
/>
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="unitsSold"
|
||||
fill={METRIC_COLORS.orders}
|
||||
name="Units Sold"
|
||||
radius={[2, 2, 0, 0]}
|
||||
opacity={0.7}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="stockoutCount"
|
||||
stroke="#ef4444"
|
||||
name="Products Stocked Out"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
159
inventory/src/components/analytics/InventoryValueTrend.tsx
Normal file
159
inventory/src/components/analytics/InventoryValueTrend.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
ComposedChart,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface ValuePoint {
|
||||
date: string;
|
||||
totalValue: number;
|
||||
productCount: number;
|
||||
}
|
||||
|
||||
type Period = 30 | 90 | 365;
|
||||
|
||||
function formatDate(dateStr: string, period: Period): string {
|
||||
const d = new Date(dateStr);
|
||||
if (period === 365) return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||
if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function InventoryValueTrend() {
|
||||
const [period, setPeriod] = useState<Period>(90);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<ValuePoint[]>({
|
||||
queryKey: ['inventory-value', period],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/inventory-value?period=${period}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch inventory value');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
const latest = data?.[data.length - 1];
|
||||
const earliest = data?.[0];
|
||||
const change = latest && earliest ? latest.totalValue - earliest.totalValue : 0;
|
||||
const changePct = earliest && earliest.totalValue > 0
|
||||
? ((change / earliest.totalValue) * 100).toFixed(1)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Inventory Value Over Time</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Total stock investment (cost) with product count overlay
|
||||
{latest && (
|
||||
<span className="ml-2">
|
||||
— Current: <span className="font-medium">{formatCurrency(latest.totalValue)}</span>
|
||||
{' '}
|
||||
<span className={change >= 0 ? 'text-green-600' : 'text-red-600'}>
|
||||
({change >= 0 ? '+' : ''}{changePct}%)
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{([30, 90, 365] as Period[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
period === p
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
{p === 365 ? '1Y' : `${p}D`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isError ? (
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load inventory value data</p>
|
||||
</div>
|
||||
) : isLoading || !data ? (
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading inventory value...</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<ComposedChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="valueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={METRIC_COLORS.revenue} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={METRIC_COLORS.revenue} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v) => formatDate(v, period)}
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={period === 365 ? 29 : period === 90 ? 6 : 2}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tickFormatter={formatCurrency}
|
||||
tick={{ fontSize: 12 }}
|
||||
width={70}
|
||||
label={{ value: 'Stock Value', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 12 }}
|
||||
width={60}
|
||||
label={{ value: 'Products', angle: 90, position: 'insideRight', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(v) => new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
formatter={(value: number, name: string) => [
|
||||
name === 'Stock Value' ? formatCurrency(value) : value.toLocaleString(),
|
||||
name,
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="totalValue"
|
||||
fill="url(#valueGradient)"
|
||||
stroke={METRIC_COLORS.revenue}
|
||||
strokeWidth={2}
|
||||
name="Stock Value"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="productCount"
|
||||
stroke={METRIC_COLORS.orders}
|
||||
name="Products in Stock"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
226
inventory/src/components/analytics/PortfolioAnalysis.tsx
Normal file
226
inventory/src/components/analytics/PortfolioAnalysis.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
CartesianGrid,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { PackageX, Archive } from 'lucide-react';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface AbcItem {
|
||||
abcClass: string;
|
||||
productCount: number;
|
||||
revenue: number;
|
||||
stockCost: number;
|
||||
profit: number;
|
||||
unitsSold: number;
|
||||
}
|
||||
|
||||
interface StockIssues {
|
||||
deadStockCount: number;
|
||||
deadStockCost: number;
|
||||
deadStockRetail: number;
|
||||
overstockCount: number;
|
||||
overstockCost: number;
|
||||
overstockRetail: number;
|
||||
}
|
||||
|
||||
interface PortfolioData {
|
||||
abcBreakdown: AbcItem[];
|
||||
stockIssues: StockIssues;
|
||||
}
|
||||
|
||||
export function PortfolioAnalysis() {
|
||||
const { data, isLoading, isError } = useQuery<PortfolioData>({
|
||||
queryKey: ['portfolio-analysis'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/portfolio`);
|
||||
if (!response.ok) throw new Error('Failed to fetch portfolio analysis');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Portfolio & ABC Analysis</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load portfolio data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Portfolio & ABC Analysis</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading portfolio...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Include all classes — rename N/A to "Unclassified"
|
||||
const allClasses = data.abcBreakdown.map(r => ({
|
||||
...r,
|
||||
abcClass: r.abcClass === 'N/A' ? 'Unclassified' : r.abcClass,
|
||||
}));
|
||||
const totalRevenue = allClasses.reduce((s, r) => s + r.revenue, 0);
|
||||
const totalStockCost = allClasses.reduce((s, r) => s + r.stockCost, 0);
|
||||
const totalProducts = allClasses.reduce((s, r) => s + r.productCount, 0);
|
||||
|
||||
// Compute percentage data for the grouped bar chart
|
||||
const chartData = allClasses.map(r => ({
|
||||
abcClass: r.abcClass === 'Unclassified' ? 'Unclassified' : `Class ${r.abcClass}`,
|
||||
'% of Products': totalProducts > 0 ? Number(((r.productCount / totalProducts) * 100).toFixed(1)) : 0,
|
||||
'% of Revenue': totalRevenue > 0 ? Number(((r.revenue / totalRevenue) * 100).toFixed(1)) : 0,
|
||||
'% of Stock Investment': totalStockCost > 0 ? Number(((r.stockCost / totalStockCost) * 100).toFixed(1)) : 0,
|
||||
}));
|
||||
|
||||
const abcOnly = allClasses.filter(r => ['A', 'B', 'C'].includes(r.abcClass));
|
||||
const abcRevenue = abcOnly.reduce((s, r) => s + r.revenue, 0);
|
||||
const aClass = allClasses.find(r => r.abcClass === 'A');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ABC Class Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="abcClass" tick={{ fontSize: 12 }} />
|
||||
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 12 }} />
|
||||
<Tooltip formatter={(value: number) => [`${value}%`]} />
|
||||
<Legend />
|
||||
<Bar dataKey="% of Products" fill={METRIC_COLORS.orders} radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="% of Revenue" fill={METRIC_COLORS.revenue} radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="% of Stock Investment" fill={METRIC_COLORS.aov} radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 grid-rows-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="rounded-full p-2 bg-green-500/10">
|
||||
<TrendingUpIcon className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">A-Class Products</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{aClass ? aClass.productCount.toLocaleString() : 0} products
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold">
|
||||
{abcRevenue > 0 && aClass ? ((aClass.revenue / abcRevenue) * 100).toFixed(0) : 0}% of classified revenue
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatCurrency(aClass?.revenue || 0)} (30d)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="rounded-full p-2 bg-amber-500/10">
|
||||
<Archive className="h-5 w-5 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Dead Stock</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.stockIssues.deadStockCount.toLocaleString()} products
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold text-amber-500">
|
||||
{formatCurrency(data.stockIssues.deadStockCost)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">capital tied up</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="rounded-full p-2 bg-red-500/10">
|
||||
<PackageX className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Overstock</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.stockIssues.overstockCount.toLocaleString()} products
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold text-red-500">
|
||||
{formatCurrency(data.stockIssues.overstockCost)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">excess investment</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ABC breakdown table */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-2 text-left font-medium">Class</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Products</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Revenue (30d)</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Profit (30d)</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Stock Cost</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Units Sold (30d)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allClasses.map((row) => (
|
||||
<tr key={row.abcClass} className="border-b">
|
||||
<td className="px-4 py-2 font-medium">{row.abcClass === 'Unclassified' ? 'Unclassified' : `Class ${row.abcClass}`}</td>
|
||||
<td className="px-4 py-2 text-right">{row.productCount.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(row.revenue)}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(row.profit)}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(row.stockCost)}</td>
|
||||
<td className="px-4 py-2 text-right">{row.unitsSold.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendingUpIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
|
||||
<polyline points="16 7 22 7 22 13" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ResponsiveContainer, ScatterChart, Scatter, XAxis, YAxis, Tooltip, ZAxis, LineChart, Line } from 'recharts';
|
||||
import config from '../../config';
|
||||
|
||||
interface PriceData {
|
||||
pricePoints: {
|
||||
price: number;
|
||||
salesVolume: number;
|
||||
revenue: number;
|
||||
category: string;
|
||||
}[];
|
||||
elasticity: {
|
||||
date: string;
|
||||
price: number;
|
||||
demand: number;
|
||||
}[];
|
||||
recommendations: {
|
||||
product: string;
|
||||
currentPrice: number;
|
||||
recommendedPrice: number;
|
||||
potentialRevenue: number;
|
||||
confidence: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function PriceAnalysis() {
|
||||
const { data, isLoading, error } = useQuery<PriceData>({
|
||||
queryKey: ['price-analysis'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/pricing`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
const rawData = await response.json();
|
||||
|
||||
if (!rawData || !rawData.pricePoints) {
|
||||
return {
|
||||
pricePoints: [],
|
||||
elasticity: [],
|
||||
recommendations: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pricePoints: (rawData.pricePoints || []).map((item: any) => ({
|
||||
price: Number(item.price) || 0,
|
||||
salesVolume: Number(item.salesVolume || item.salesvolume) || 0,
|
||||
revenue: Number(item.revenue) || 0,
|
||||
category: item.category || ''
|
||||
})),
|
||||
elasticity: (rawData.elasticity || []).map((item: any) => ({
|
||||
date: item.date || '',
|
||||
price: Number(item.price) || 0,
|
||||
demand: Number(item.demand) || 0
|
||||
})),
|
||||
recommendations: (rawData.recommendations || []).map((item: any) => ({
|
||||
product: item.product || '',
|
||||
currentPrice: Number(item.currentPrice || item.currentprice) || 0,
|
||||
recommendedPrice: Number(item.recommendedPrice || item.recommendedprice) || 0,
|
||||
potentialRevenue: Number(item.potentialRevenue || item.potentialrevenue) || 0,
|
||||
confidence: Number(item.confidence) || 0
|
||||
}))
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error fetching price data:', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
retry: 1
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading price analysis...</div>;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Price Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-red-500">
|
||||
Unable to load price analysis. The price metrics may need to be set up in the database.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Early return if no data to display
|
||||
if (
|
||||
data.pricePoints.length === 0 &&
|
||||
data.elasticity.length === 0 &&
|
||||
data.recommendations.length === 0
|
||||
) {
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Price Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
No price data available. This may be because the price metrics haven't been calculated yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Price Point Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart>
|
||||
<XAxis
|
||||
dataKey="price"
|
||||
name="Price"
|
||||
tickFormatter={(value) => `$${value}`}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="salesVolume"
|
||||
name="Sales Volume"
|
||||
/>
|
||||
<ZAxis
|
||||
dataKey="revenue"
|
||||
range={[50, 400]}
|
||||
name="Revenue"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Price') return [`$${value}`, name];
|
||||
if (name === 'Sales Volume') return [value.toLocaleString(), name];
|
||||
if (name === 'Revenue') return [`$${value.toLocaleString()}`, name];
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Scatter
|
||||
data={data.pricePoints}
|
||||
fill="#a78bfa"
|
||||
name="Products"
|
||||
/>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Price Elasticity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data.elasticity}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||
/>
|
||||
<YAxis yAxisId="left" orientation="left" stroke="#a78bfa" />
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
stroke="#4ade80"
|
||||
tickFormatter={(value) => `$${value}`}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(label) => new Date(label).toLocaleDateString()}
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Price') return [`$${value}`, name];
|
||||
return [value.toLocaleString(), name];
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="demand"
|
||||
stroke="#a78bfa"
|
||||
name="Demand"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="price"
|
||||
stroke="#4ade80"
|
||||
name="Price"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Price Optimization Recommendations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{data.recommendations.map((item) => (
|
||||
<div key={`${item.product}-${item.currentPrice}`} className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{item.product}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current Price: ${item.currentPrice.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 text-right space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
Recommended: ${item.recommendedPrice.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Potential Revenue: ${item.potentialRevenue.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Confidence: {item.confidence}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts';
|
||||
import config from '../../config';
|
||||
|
||||
interface ProfitData {
|
||||
byCategory: {
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
profitMargin: number;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
}[];
|
||||
overTime: {
|
||||
date: string;
|
||||
profitMargin: number;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
}[];
|
||||
topProducts: {
|
||||
product: string;
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
profitMargin: number;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function ProfitAnalysis() {
|
||||
const { data, isLoading } = useQuery<ProfitData>({
|
||||
queryKey: ['profit-analysis'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/profit`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch profit analysis');
|
||||
}
|
||||
const rawData = await response.json();
|
||||
return {
|
||||
byCategory: rawData.byCategory.map((item: any) => ({
|
||||
category: item.category || '',
|
||||
categoryPath: item.categorypath || item.category || '',
|
||||
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
|
||||
revenue: Number(item.revenue) || 0,
|
||||
cost: Number(item.cost) || 0
|
||||
})),
|
||||
overTime: rawData.overTime.map((item: any) => ({
|
||||
date: item.date || '',
|
||||
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
|
||||
revenue: Number(item.revenue) || 0,
|
||||
cost: Number(item.cost) || 0
|
||||
})),
|
||||
topProducts: rawData.topProducts.map((item: any) => ({
|
||||
product: item.product || '',
|
||||
category: item.category || '',
|
||||
categoryPath: item.categorypath || item.category || '',
|
||||
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
|
||||
revenue: Number(item.revenue) || 0,
|
||||
cost: Number(item.cost) || 0
|
||||
}))
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <div>Loading profit analysis...</div>;
|
||||
}
|
||||
|
||||
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profit Margins by Category</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data.byCategory}>
|
||||
<XAxis
|
||||
dataKey="categoryPath"
|
||||
tick={({ x, y, payload }) => (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
dy={16}
|
||||
textAnchor="end"
|
||||
fill="#888888"
|
||||
transform="rotate(-35)"
|
||||
>
|
||||
{getShortCategoryName(payload.value)}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||
<Tooltip
|
||||
formatter={(value: number, _: string, props: any) => [
|
||||
`${value.toFixed(1)}%`,
|
||||
<div key="tooltip">
|
||||
<div className="font-medium">Category Path:</div>
|
||||
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
|
||||
<div className="mt-1">Profit Margin</div>
|
||||
</div>
|
||||
]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="profitMargin"
|
||||
fill="#4ade80"
|
||||
name="Profit Margin"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profit Margin Trend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data.overTime}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||
<Tooltip
|
||||
labelFormatter={(label) => new Date(label).toLocaleDateString()}
|
||||
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="profitMargin"
|
||||
stroke="#4ade80"
|
||||
name="Profit Margin"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Performing Products by Profit Margin</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{data.topProducts.map((product) => (
|
||||
<div key={`${product.product}-${product.category}`} className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{product.product}</p>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium">Category:</p>
|
||||
<p>{product.categoryPath}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Revenue: ${product.revenue.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{product.profitMargin.toFixed(1)}% margin
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cost: ${product.cost.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
inventory/src/components/analytics/SeasonalPatterns.tsx
Normal file
240
inventory/src/components/analytics/SeasonalPatterns.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Legend,
|
||||
Tooltip,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
import { Sun, Snowflake } from 'lucide-react';
|
||||
|
||||
interface PatternRow {
|
||||
pattern: string;
|
||||
productCount: number;
|
||||
stockCost: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
interface PeakSeasonRow {
|
||||
month: string;
|
||||
productCount: number;
|
||||
stockCost: number;
|
||||
}
|
||||
|
||||
interface SeasonalData {
|
||||
patterns: PatternRow[];
|
||||
peakSeasons: PeakSeasonRow[];
|
||||
}
|
||||
|
||||
const PATTERN_COLORS: Record<string, string> = {
|
||||
none: '#94a3b8', // slate — no seasonality
|
||||
moderate: METRIC_COLORS.comparison, // amber
|
||||
strong: METRIC_COLORS.revenue, // emerald
|
||||
unknown: '#cbd5e1', // light slate
|
||||
};
|
||||
|
||||
const PATTERN_LABELS: Record<string, string> = {
|
||||
none: 'No Seasonality',
|
||||
moderate: 'Moderate',
|
||||
strong: 'Strong',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
export function SeasonalPatterns() {
|
||||
const { data, isLoading, isError } = useQuery<SeasonalData>({
|
||||
queryKey: ['seasonal-patterns'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/seasonal`);
|
||||
if (!response.ok) throw new Error('Failed to fetch seasonal data');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Seasonal Patterns</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load seasonal data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Seasonal Patterns</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading seasonal data...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const seasonal = data.patterns.filter(p => p.pattern === 'moderate' || p.pattern === 'strong');
|
||||
const seasonalCount = seasonal.reduce((s, p) => s + p.productCount, 0);
|
||||
const seasonalStockCost = seasonal.reduce((s, p) => s + p.stockCost, 0);
|
||||
const totalProducts = data.patterns.reduce((s, p) => s + p.productCount, 0);
|
||||
const seasonalPct = totalProducts > 0 ? ((seasonalCount / totalProducts) * 100).toFixed(0) : '0';
|
||||
|
||||
const donutData = data.patterns.map(p => ({
|
||||
name: PATTERN_LABELS[p.pattern] || p.pattern,
|
||||
value: p.productCount,
|
||||
color: PATTERN_COLORS[p.pattern] || '#94a3b8',
|
||||
stockCost: p.stockCost,
|
||||
revenue: p.revenue,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-amber-500/10">
|
||||
<Sun className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Seasonal Products</p>
|
||||
<p className="text-xl font-bold">{seasonalCount.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{seasonalPct}% of in-stock products</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-emerald-500/10">
|
||||
<Snowflake className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Seasonal Stock Value</p>
|
||||
<p className="text-xl font-bold">{formatCurrency(seasonalStockCost)}</p>
|
||||
<p className="text-xs text-muted-foreground">capital in seasonal items</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-blue-500/10">
|
||||
<Sun className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Peak Months Tracked</p>
|
||||
<p className="text-xl font-bold">{data.peakSeasons.length}</p>
|
||||
<p className="text-xs text-muted-foreground">months with seasonal peaks</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Donut chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Demand Seasonality Distribution</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Products by seasonal demand pattern (in-stock only)
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={donutData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
label={({ name, value }) => `${name} (${value.toLocaleString()})`}
|
||||
>
|
||||
{donutData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">{d.name}</p>
|
||||
<p>{d.value.toLocaleString()} products</p>
|
||||
<p>Stock value: {formatCurrency(d.stockCost)}</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value) => <span className="text-xs">{value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Peak season bar chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Peak Season Distribution</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Which months seasonal products peak (moderate + strong patterns)
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.peakSeasons.length === 0 ? (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">No peak season data available</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data.peakSeasons}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as PeakSeasonRow;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">{d.month}</p>
|
||||
<p>{d.productCount.toLocaleString()} seasonal products peak</p>
|
||||
<p>Stock value: {formatCurrency(d.stockCost)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="productCount"
|
||||
fill={METRIC_COLORS.comparison}
|
||||
name="Products"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import config from '../../config';
|
||||
|
||||
interface StockData {
|
||||
turnoverByCategory: {
|
||||
category: string;
|
||||
turnoverRate: number;
|
||||
averageStock: number;
|
||||
totalSales: number;
|
||||
}[];
|
||||
stockLevels: {
|
||||
date: string;
|
||||
inStock: number;
|
||||
lowStock: number;
|
||||
outOfStock: number;
|
||||
}[];
|
||||
criticalItems: {
|
||||
product: string;
|
||||
sku: string;
|
||||
stockQuantity: number;
|
||||
reorderPoint: number;
|
||||
turnoverRate: number;
|
||||
daysUntilStockout: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function StockAnalysis() {
|
||||
const { data, isLoading, error } = useQuery<StockData>({
|
||||
queryKey: ['stock-analysis'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/stock`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
const rawData = await response.json();
|
||||
|
||||
if (!rawData || !rawData.turnoverByCategory) {
|
||||
return {
|
||||
turnoverByCategory: [],
|
||||
stockLevels: [],
|
||||
criticalItems: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
turnoverByCategory: (rawData.turnoverByCategory || []).map((item: any) => ({
|
||||
category: item.category || '',
|
||||
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
|
||||
averageStock: Number(item.averageStock || item.averagestock) || 0,
|
||||
totalSales: Number(item.totalSales || item.totalsales) || 0
|
||||
})),
|
||||
stockLevels: (rawData.stockLevels || []).map((item: any) => ({
|
||||
date: item.date || '',
|
||||
inStock: Number(item.inStock || item.instock) || 0,
|
||||
lowStock: Number(item.lowStock || item.lowstock) || 0,
|
||||
outOfStock: Number(item.outOfStock || item.outofstock) || 0
|
||||
})),
|
||||
criticalItems: (rawData.criticalItems || []).map((item: any) => ({
|
||||
product: item.product || '',
|
||||
sku: item.sku || '',
|
||||
stockQuantity: Number(item.stockQuantity || item.stockquantity) || 0,
|
||||
reorderPoint: Number(item.reorderPoint || item.reorderpoint) || 0,
|
||||
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
|
||||
daysUntilStockout: Number(item.daysUntilStockout || item.daysuntilstockout) || 0
|
||||
}))
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error fetching stock data:', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
retry: 1
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading stock analysis...</div>;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-red-500">
|
||||
Unable to load stock analysis. The stock metrics may need to be set up in the database.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Early return if no data to display
|
||||
if (
|
||||
data.turnoverByCategory.length === 0 &&
|
||||
data.stockLevels.length === 0 &&
|
||||
data.criticalItems.length === 0
|
||||
) {
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
No stock data available. This may be because the stock metrics haven't been calculated yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getStockStatus = (daysUntilStockout: number) => {
|
||||
if (daysUntilStockout <= 7) {
|
||||
return <Badge variant="destructive">Critical</Badge>;
|
||||
}
|
||||
if (daysUntilStockout <= 14) {
|
||||
return <Badge variant="outline">Warning</Badge>;
|
||||
}
|
||||
return <Badge variant="secondary">OK</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Turnover by Category</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data.turnoverByCategory}>
|
||||
<XAxis dataKey="category" />
|
||||
<YAxis tickFormatter={(value) => `${value.toFixed(1)}x`} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value.toFixed(1)}x`, 'Turnover Rate']}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="turnoverRate"
|
||||
fill="#fbbf24"
|
||||
name="Turnover Rate"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Level Trends</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data.stockLevels}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
labelFormatter={(label) => new Date(label).toLocaleDateString()}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="inStock"
|
||||
stroke="#4ade80"
|
||||
name="In Stock"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="lowStock"
|
||||
stroke="#fbbf24"
|
||||
name="Low Stock"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="outOfStock"
|
||||
stroke="#f87171"
|
||||
name="Out of Stock"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Critical Stock Items</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{data.criticalItems.map((item) => (
|
||||
<div key={`${item.sku}-${item.product}`} className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{item.product}</p>
|
||||
{getStockStatus(item.daysUntilStockout)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SKU: {item.sku}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 text-right space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{item.stockQuantity} in stock
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reorder at: {item.reorderPoint}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.daysUntilStockout} days until stockout
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
inventory/src/components/analytics/StockHealth.tsx
Normal file
257
inventory/src/components/analytics/StockHealth.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { AlertTriangle, ShieldCheck, DollarSign } from 'lucide-react';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface CoverBucket {
|
||||
bucket: string;
|
||||
productCount: number;
|
||||
stockCost: number;
|
||||
}
|
||||
|
||||
interface DemandPattern {
|
||||
pattern: string;
|
||||
productCount: number;
|
||||
revenue: number;
|
||||
stockCost: number;
|
||||
}
|
||||
|
||||
interface ServiceStats {
|
||||
avgFillRate: number;
|
||||
avgServiceLevel: number;
|
||||
totalStockoutIncidents: number;
|
||||
totalLostSalesIncidents: number;
|
||||
totalLostUnits: number;
|
||||
totalLostRevenue: number;
|
||||
productsWithStockouts: number;
|
||||
avgStockoutRate: number;
|
||||
}
|
||||
|
||||
interface StockHealthData {
|
||||
coverDistribution: CoverBucket[];
|
||||
demandPatterns: DemandPattern[];
|
||||
serviceStats: ServiceStats;
|
||||
}
|
||||
|
||||
// Color palette for demand pattern donut chart
|
||||
const DEMAND_COLORS = [
|
||||
METRIC_COLORS.revenue, // emerald
|
||||
METRIC_COLORS.orders, // blue
|
||||
METRIC_COLORS.comparison, // amber
|
||||
METRIC_COLORS.aov, // violet
|
||||
METRIC_COLORS.secondary, // cyan
|
||||
];
|
||||
|
||||
function getCoverColor(bucket: string): string {
|
||||
if (bucket.includes('Stockout')) return '#ef4444'; // red
|
||||
if (bucket.includes('1-7')) return METRIC_COLORS.expense; // orange — critical low
|
||||
if (bucket.includes('8-14')) return METRIC_COLORS.comparison; // amber — low
|
||||
if (bucket.includes('15-30')) return '#eab308'; // yellow — watch
|
||||
if (bucket.includes('31-60')) return METRIC_COLORS.revenue; // emerald — healthy
|
||||
if (bucket.includes('61-90')) return METRIC_COLORS.orders; // blue — comfortable
|
||||
if (bucket.includes('91-180')) return METRIC_COLORS.aov; // violet — high
|
||||
return METRIC_COLORS.secondary; // cyan — excess
|
||||
}
|
||||
|
||||
export function StockHealth() {
|
||||
const { data, isLoading, isError } = useQuery<StockHealthData>({
|
||||
queryKey: ['stock-health'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/stock-health`);
|
||||
if (!response.ok) throw new Error('Failed to fetch stock health');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Demand & Stock Health</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load stock health data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Demand & Stock Health</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading stock health...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { serviceStats } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Service Level Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-green-500/10">
|
||||
<ShieldCheck className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Fill Rate</p>
|
||||
<p className="text-xl font-bold">{serviceStats.avgFillRate}%</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-blue-500/10">
|
||||
<ShieldCheck className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Service Level</p>
|
||||
<p className="text-xl font-bold">{serviceStats.avgServiceLevel}%</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-red-500/10">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Stockout Incidents</p>
|
||||
<p className="text-xl font-bold">{serviceStats.totalStockoutIncidents.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{serviceStats.productsWithStockouts} products affected</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-amber-500/10">
|
||||
<DollarSign className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Est. Lost Revenue</p>
|
||||
<p className="text-xl font-bold">{formatCurrency(serviceStats.totalLostRevenue)}</p>
|
||||
<p className="text-xs text-muted-foreground">{Math.round(serviceStats.totalLostUnits).toLocaleString()} units</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Stock Cover Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Cover Distribution</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Days of stock cover across active replenishable products
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data.coverDistribution}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tick={{ fontSize: 10 }}
|
||||
angle={-30}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as CoverBucket;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">{d.bucket}</p>
|
||||
<p>{d.productCount.toLocaleString()} products</p>
|
||||
<p>Stock value: {formatCurrency(d.stockCost)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="productCount" name="Products" radius={[4, 4, 0, 0]}>
|
||||
{data.coverDistribution.map((entry, i) => (
|
||||
<Cell key={i} fill={getCoverColor(entry.bucket)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Demand Pattern Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Demand Patterns</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Distribution of demand variability across selling products
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.demandPatterns}
|
||||
dataKey="productCount"
|
||||
nameKey="pattern"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
label={({ pattern, productCount }) =>
|
||||
`${pattern} (${productCount.toLocaleString()})`
|
||||
}
|
||||
>
|
||||
{data.demandPatterns.map((_, i) => (
|
||||
<Cell key={i} fill={DEMAND_COLORS[i % DEMAND_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as DemandPattern;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1 capitalize">{d.pattern}</p>
|
||||
<p>{d.productCount.toLocaleString()} products</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
|
||||
<p>Stock value: {formatCurrency(d.stockCost)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value) => <span className="capitalize text-xs">{value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
inventory/src/components/analytics/StockoutRisk.tsx
Normal file
187
inventory/src/components/analytics/StockoutRisk.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
ZAxis,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface RiskProduct {
|
||||
title: string;
|
||||
sku: string;
|
||||
brand: string;
|
||||
leadTimeDays: number;
|
||||
sellsOutInDays: number;
|
||||
currentStock: number;
|
||||
velocityDaily: number;
|
||||
revenue30d: number;
|
||||
abcClass: string;
|
||||
}
|
||||
|
||||
interface RiskSummary {
|
||||
atRiskCount: number;
|
||||
criticalACount: number;
|
||||
atRiskRevenue: number;
|
||||
}
|
||||
|
||||
interface StockoutRiskData {
|
||||
summary: RiskSummary;
|
||||
products: RiskProduct[];
|
||||
}
|
||||
|
||||
function getRiskColor(product: RiskProduct): string {
|
||||
const buffer = product.sellsOutInDays - product.leadTimeDays;
|
||||
if (buffer <= 0) return '#ef4444'; // Already past lead time — critical
|
||||
if (buffer <= 7) return METRIC_COLORS.comparison; // Within a week — warning
|
||||
return METRIC_COLORS.revenue; // Healthy buffer
|
||||
}
|
||||
|
||||
export function StockoutRisk() {
|
||||
const { data, isLoading, isError } = useQuery<StockoutRiskData>({
|
||||
queryKey: ['stockout-risk'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/analytics/stockout-risk`);
|
||||
if (!response.ok) throw new Error('Failed to fetch stockout risk');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Reorder Risk</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">Failed to load risk data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Reorder Risk</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading risk data...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, products } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">At Risk Products</p>
|
||||
<p className="text-2xl font-bold text-red-500">{summary.atRiskCount}</p>
|
||||
<p className="text-xs text-muted-foreground">sells out before lead time</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">Critical A-Class</p>
|
||||
<p className="text-2xl font-bold text-red-500">{summary.criticalACount}</p>
|
||||
<p className="text-xs text-muted-foreground">top sellers at risk</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">At-Risk Revenue</p>
|
||||
<p className="text-2xl font-bold text-amber-500">
|
||||
{formatCurrency(summary.atRiskRevenue)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">monthly revenue exposed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lead Time vs Sell-Out Timeline</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Products below the diagonal line will stock out before replenishment arrives (incl. on-order stock)
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<ScatterChart>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="leadTimeDays"
|
||||
name="Lead Time"
|
||||
tick={{ fontSize: 11 }}
|
||||
type="number"
|
||||
label={{ value: 'Lead Time (days)', position: 'insideBottom', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="sellsOutInDays"
|
||||
name="Sells Out In"
|
||||
tick={{ fontSize: 11 }}
|
||||
type="number"
|
||||
label={{ value: 'Sells Out In (days)', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
|
||||
/>
|
||||
<ZAxis dataKey="revenue30d" range={[30, 300]} name="Revenue" />
|
||||
{/* Diagonal risk line (y = x): products below this stock out before replenishment */}
|
||||
<Scatter
|
||||
data={(() => {
|
||||
const max = products.length > 0 ? Math.max(...products.map(d => Math.max(d.leadTimeDays, d.sellsOutInDays))) : 100;
|
||||
return [
|
||||
{ leadTimeDays: 0, sellsOutInDays: 0, revenue30d: 0 },
|
||||
{ leadTimeDays: max, sellsOutInDays: max, revenue30d: 0 },
|
||||
];
|
||||
})()}
|
||||
line={{ stroke: '#9ca3af', strokeDasharray: '6 3', strokeWidth: 1.5 }}
|
||||
shape={() => <circle r={0} />}
|
||||
legendType="none"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as RiskProduct;
|
||||
if (!d.title) return null; // skip diagonal line points
|
||||
const buffer = d.sellsOutInDays - d.leadTimeDays;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm max-w-xs">
|
||||
<p className="font-medium mb-1 truncate">{d.title}</p>
|
||||
<p className="text-xs text-muted-foreground mb-1">{d.sku} ({d.abcClass})</p>
|
||||
<p>Lead time: {d.leadTimeDays}d</p>
|
||||
<p>Sells out in: {d.sellsOutInDays}d</p>
|
||||
<p className={buffer <= 0 ? 'text-red-500 font-medium' : ''}>
|
||||
Buffer: {buffer}d {buffer <= 0 ? '(AT RISK)' : ''}
|
||||
</p>
|
||||
<p>Stock: {d.currentStock} units</p>
|
||||
<p>Velocity: {d.velocityDaily.toFixed(1)}/day</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Scatter data={products} fillOpacity={0.7}>
|
||||
{products.map((entry, i) => (
|
||||
<Cell key={i} fill={getRiskColor(entry)} />
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, ScatterChart, Scatter, ZAxis } from 'recharts';
|
||||
import config from '../../config';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface VendorData {
|
||||
performance: {
|
||||
vendor: string;
|
||||
salesVolume: number;
|
||||
profitMargin: number;
|
||||
stockTurnover: number;
|
||||
productCount: number;
|
||||
growth: number;
|
||||
}[];
|
||||
comparison?: {
|
||||
vendor: string;
|
||||
salesPerProduct: number;
|
||||
averageMargin: number;
|
||||
size: number;
|
||||
}[];
|
||||
trends?: {
|
||||
vendor: string;
|
||||
month: string;
|
||||
sales: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function VendorPerformance() {
|
||||
const [vendorData, setVendorData] = useState<VendorData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Use plain fetch to bypass cache issues with React Query
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Add cache-busting parameter
|
||||
const response = await fetch(`${config.apiUrl}/analytics/vendors?nocache=${Date.now()}`, {
|
||||
headers: {
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0"
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
if (!rawData || !rawData.performance) {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
|
||||
// Create a complete structure even if some parts are missing
|
||||
const data: VendorData = {
|
||||
performance: rawData.performance.map((vendor: any) => ({
|
||||
vendor: vendor.vendor || '',
|
||||
salesVolume: vendor.salesVolume !== null ? Number(vendor.salesVolume) : 0,
|
||||
profitMargin: vendor.profitMargin !== null ? Number(vendor.profitMargin) : 0,
|
||||
stockTurnover: vendor.stockTurnover !== null ? Number(vendor.stockTurnover) : 0,
|
||||
productCount: Number(vendor.productCount) || 0,
|
||||
growth: vendor.growth !== null ? Number(vendor.growth) : 0
|
||||
})),
|
||||
comparison: rawData.comparison?.map((vendor: any) => ({
|
||||
vendor: vendor.vendor || '',
|
||||
salesPerProduct: vendor.salesPerProduct !== null ? Number(vendor.salesPerProduct) : 0,
|
||||
averageMargin: vendor.averageMargin !== null ? Number(vendor.averageMargin) : 0,
|
||||
size: Number(vendor.size) || 0
|
||||
})) || [],
|
||||
trends: rawData.trends?.map((vendor: any) => ({
|
||||
vendor: vendor.vendor || '',
|
||||
month: vendor.month || '',
|
||||
sales: Number(vendor.sales) || 0
|
||||
})) || []
|
||||
};
|
||||
|
||||
setVendorData(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching vendor data:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading vendor performance...</div>;
|
||||
}
|
||||
|
||||
if (error || !vendorData) {
|
||||
return <div className="text-red-500">Error loading vendor data: {error}</div>;
|
||||
}
|
||||
|
||||
// Ensure we have at least the performance data
|
||||
const sortedPerformance = vendorData.performance
|
||||
.sort((a, b) => b.salesVolume - a.salesVolume)
|
||||
.slice(0, 10);
|
||||
|
||||
// Use simplified version if comparison data is missing
|
||||
const hasComparisonData = vendorData.comparison && vendorData.comparison.length > 0;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Vendors by Sales Volume</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={sortedPerformance}>
|
||||
<XAxis dataKey="vendor" />
|
||||
<YAxis tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales Volume']}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="salesVolume"
|
||||
fill="#60a5fa"
|
||||
name="Sales Volume"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasComparisonData ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vendor Performance Matrix</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart>
|
||||
<XAxis
|
||||
dataKey="salesPerProduct"
|
||||
name="Sales per Product"
|
||||
tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="averageMargin"
|
||||
name="Average Margin"
|
||||
tickFormatter={(value) => `${value.toFixed(0)}%`}
|
||||
/>
|
||||
<ZAxis
|
||||
dataKey="size"
|
||||
range={[50, 400]}
|
||||
name="Product Count"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Sales per Product') return [`$${value.toLocaleString()}`, name];
|
||||
if (name === 'Average Margin') return [`${value.toFixed(1)}%`, name];
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Scatter
|
||||
data={vendorData.comparison}
|
||||
fill="#60a5fa"
|
||||
name="Vendors"
|
||||
/>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vendor Profit Margins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={sortedPerformance}>
|
||||
<XAxis dataKey="vendor" />
|
||||
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="profitMargin"
|
||||
fill="#4ade80"
|
||||
name="Profit Margin"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vendor Performance Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{sortedPerformance.map((vendor) => (
|
||||
<div key={`${vendor.vendor}-${vendor.salesVolume}`} className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{vendor.vendor}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{vendor.productCount} products
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 text-right space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
${vendor.salesVolume.toLocaleString()} sales
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{vendor.profitMargin.toFixed(1)}% margin
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{vendor.stockTurnover.toFixed(1)}x turnover
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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%">
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
597
inventory/src/components/newsletter/CampaignHistoryDialog.tsx
Normal file
597
inventory/src/components/newsletter/CampaignHistoryDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
inventory/src/components/newsletter/NewsletterStats.tsx
Normal file
110
inventory/src/components/newsletter/NewsletterStats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
471
inventory/src/components/newsletter/RecommendationTable.tsx
Normal file
471
inventory/src/components/newsletter/RecommendationTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
294
inventory/src/components/overview/ForecastAccuracy.tsx
Normal file
294
inventory/src/components/overview/ForecastAccuracy.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { BarChart, Bar, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip, Cell, LineChart, Line } from "recharts"
|
||||
import config from "@/config"
|
||||
import { Target, TrendingDown, ArrowUpDown } from "lucide-react"
|
||||
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { PHASE_CONFIG } from "@/utils/lifecyclePhases"
|
||||
|
||||
interface OverallMetrics {
|
||||
sampleSize: number
|
||||
totalActual: number
|
||||
totalForecast: number
|
||||
mae: number | null
|
||||
wmape: number | null
|
||||
bias: number | null
|
||||
rmse: number | null
|
||||
}
|
||||
|
||||
interface PhaseAccuracy {
|
||||
phase: string
|
||||
sampleSize: number
|
||||
totalActual: number
|
||||
totalForecast: number
|
||||
mae: number | null
|
||||
wmape: number | null
|
||||
bias: number | null
|
||||
rmse: number | null
|
||||
}
|
||||
|
||||
interface LeadTimeAccuracy {
|
||||
bucket: string
|
||||
sampleSize: number
|
||||
mae: number | null
|
||||
wmape: number | null
|
||||
bias: number | null
|
||||
rmse: number | null
|
||||
}
|
||||
|
||||
interface AccuracyTrendPoint {
|
||||
date: string
|
||||
mae: number | null
|
||||
wmape: number | null
|
||||
bias: number | null
|
||||
sampleSize: number
|
||||
}
|
||||
|
||||
interface AccuracyData {
|
||||
hasData: boolean
|
||||
message?: string
|
||||
computedAt?: string
|
||||
daysOfHistory?: number
|
||||
historyRange?: { from: string; to: string }
|
||||
overall?: OverallMetrics
|
||||
byPhase?: PhaseAccuracy[]
|
||||
byLeadTime?: LeadTimeAccuracy[]
|
||||
byMethod?: { method: string; sampleSize: number; mae: number | null; wmape: number | null; bias: number | null }[]
|
||||
dailyTrend?: { date: string; mae: number | null; wmape: number | null; bias: number | null }[]
|
||||
accuracyTrend?: AccuracyTrendPoint[]
|
||||
}
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-16 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
function formatWmape(wmape: number | null): string {
|
||||
if (wmape === null) return "N/A"
|
||||
return `${wmape.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatBias(bias: number | null): string {
|
||||
if (bias === null) return "N/A"
|
||||
const sign = bias > 0 ? "+" : ""
|
||||
return `${sign}${bias.toFixed(3)}`
|
||||
}
|
||||
|
||||
function getAccuracyColor(wmape: number | null): string {
|
||||
if (wmape === null) return "text-muted-foreground"
|
||||
if (wmape <= 30) return "text-green-600"
|
||||
if (wmape <= 50) return "text-yellow-600"
|
||||
return "text-red-600"
|
||||
}
|
||||
|
||||
export function ForecastAccuracy() {
|
||||
const { data, error, isLoading } = useQuery<AccuracyData>({
|
||||
queryKey: ["forecast-accuracy"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/forecast/accuracy`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch forecast accuracy")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
refetchInterval: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">Forecast Accuracy</h3>
|
||||
<p className="text-sm text-destructive">Failed to load accuracy data</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoading && data && !data.hasData) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">Forecast Accuracy</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Accuracy data will be available after the forecast engine has run for at least 2 days,
|
||||
building up historical comparisons between predictions and actual sales.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const phaseChartData = (data?.byPhase || [])
|
||||
.filter(p => p.wmape !== null && p.phase !== 'dormant')
|
||||
.map(p => ({
|
||||
phase: PHASE_CONFIG[p.phase]?.label || p.phase,
|
||||
rawPhase: p.phase,
|
||||
wmape: p.wmape,
|
||||
mae: p.mae,
|
||||
bias: p.bias,
|
||||
sampleSize: p.sampleSize,
|
||||
}))
|
||||
.sort((a, b) => (a.wmape ?? 100) - (b.wmape ?? 100))
|
||||
|
||||
const leadTimeData = (data?.byLeadTime || []).map(lt => ({
|
||||
bucket: lt.bucket,
|
||||
wmape: lt.wmape,
|
||||
mae: lt.mae,
|
||||
sampleSize: lt.sampleSize,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">Forecast Accuracy</h3>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<MetricSkeleton />
|
||||
<MetricSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Headline metrics */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">WMAPE</p>
|
||||
</div>
|
||||
<p className={`text-lg font-bold ${getAccuracyColor(data?.overall?.wmape ?? null)}`}>
|
||||
{formatWmape(data?.overall?.wmape ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">MAE</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">
|
||||
{data?.overall?.mae !== null ? data?.overall?.mae?.toFixed(2) : "N/A"}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-1">units</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Bias</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">
|
||||
{formatBias(data?.overall?.bias ?? null)}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-1">
|
||||
{(data?.overall?.bias ?? 0) > 0 ? "over" : (data?.overall?.bias ?? 0) < 0 ? "under" : ""}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase accuracy bar */}
|
||||
{phaseChartData.length > 0 && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">WMAPE by Lifecycle Phase</p>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="space-y-1">
|
||||
{phaseChartData.map((p) => {
|
||||
const cfg = PHASE_CONFIG[p.rawPhase] || { label: p.phase, color: "#94A3B8" }
|
||||
const maxWmape = Math.max(...phaseChartData.map(d => d.wmape ?? 0), 1)
|
||||
const barWidth = ((p.wmape ?? 0) / maxWmape) * 100
|
||||
return (
|
||||
<UITooltip key={p.rawPhase}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground w-16 text-right shrink-0">{cfg.label}</span>
|
||||
<div className="flex-1 h-3 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${barWidth}%`,
|
||||
backgroundColor: cfg.color,
|
||||
minWidth: barWidth > 0 ? 4 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium w-10 text-right shrink-0">
|
||||
{formatWmape(p.wmape)}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<div className="font-medium">{cfg.label}</div>
|
||||
<div>WMAPE: {formatWmape(p.wmape)}</div>
|
||||
<div>MAE: {p.mae?.toFixed(3) ?? "N/A"} units</div>
|
||||
<div>Bias: {formatBias(p.bias)}</div>
|
||||
<div className="text-muted-foreground">{p.sampleSize.toLocaleString()} samples</div>
|
||||
</TooltipContent>
|
||||
</UITooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lead time accuracy chart */}
|
||||
{leadTimeData.length > 0 && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Accuracy by Lead Time</p>
|
||||
<div className="h-[120px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={leadTimeData} margin={{ top: 5, right: 0, left: -30, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
formatter={(value: number) => [`${value?.toFixed(1)}%`, "WMAPE"]}
|
||||
/>
|
||||
<Bar dataKey="wmape" radius={[4, 4, 0, 0]}>
|
||||
{leadTimeData.map((entry, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
fill={(entry.wmape ?? 0) <= 30 ? "#22C55E" : (entry.wmape ?? 0) <= 50 ? "#F59E0B" : "#EF4444"}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accuracy trend sparkline */}
|
||||
{data?.accuracyTrend && data.accuracyTrend.length > 1 && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Accuracy Trend (WMAPE)</p>
|
||||
<div className="h-[60px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data.accuracyTrend} margin={{ top: 5, right: 0, left: -60, bottom: 0 }}>
|
||||
<YAxis tickLine={false} axisLine={false} tick={false} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="wmape"
|
||||
stroke="#8884D8"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer info */}
|
||||
{data?.daysOfHistory !== undefined && (
|
||||
<p className="text-[10px] text-muted-foreground mt-3 mb-2">
|
||||
Based on {data.daysOfHistory} day{data.daysOfHistory !== 1 ? "s" : ""} of history
|
||||
{data.overall?.sampleSize ? ` (${data.overall.sampleSize.toLocaleString()} samples)` : ""}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,50 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
|
||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts"
|
||||
import { useState } from "react"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { TrendingUp, DollarSign } from "lucide-react"
|
||||
import { DateRange } from "react-day-picker"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { TrendingUp, DollarSign, Target } from "lucide-react"
|
||||
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ForecastAccuracy } from "@/components/overview/ForecastAccuracy"
|
||||
import { addDays, format } from "date-fns"
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
||||
import { PHASE_CONFIG, PHASE_KEYS } from "@/utils/lifecyclePhases"
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
type Period = 30 | 90 | 'year';
|
||||
|
||||
function getEndDate(period: Period): Date {
|
||||
if (period === 'year') return new Date(new Date().getFullYear(), 11, 31);
|
||||
return addDays(new Date(), period);
|
||||
}
|
||||
|
||||
interface PhaseData {
|
||||
phase: string
|
||||
products: number
|
||||
units: number
|
||||
revenue: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
interface DailyPhaseData {
|
||||
date: string
|
||||
preorder: number
|
||||
launch: number
|
||||
decay: number
|
||||
mature: number
|
||||
slow_mover: number
|
||||
dormant: number
|
||||
}
|
||||
|
||||
interface ForecastData {
|
||||
forecastSales: number
|
||||
forecastRevenue: string
|
||||
forecastRevenue: number
|
||||
confidenceLevel: number
|
||||
dailyForecasts: {
|
||||
date: string
|
||||
@@ -19,6 +52,8 @@ interface ForecastData {
|
||||
revenue: string
|
||||
confidence: number
|
||||
}[]
|
||||
dailyForecastsByPhase?: DailyPhaseData[]
|
||||
phaseBreakdown?: PhaseData[]
|
||||
categoryForecasts: {
|
||||
category: string
|
||||
units: number
|
||||
@@ -28,17 +63,14 @@ interface ForecastData {
|
||||
}
|
||||
|
||||
export function ForecastMetrics() {
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
from: new Date(),
|
||||
to: addDays(new Date(), 30),
|
||||
});
|
||||
const [period, setPeriod] = useState<Period>(30);
|
||||
|
||||
const { data, error, isLoading } = useQuery<ForecastData>({
|
||||
queryKey: ["forecast-metrics", dateRange],
|
||||
queryKey: ["forecast-metrics", period],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
startDate: dateRange.from?.toISOString() || "",
|
||||
endDate: dateRange.to?.toISOString() || "",
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: getEndDate(period).toISOString(),
|
||||
});
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?${params}`)
|
||||
if (!response.ok) {
|
||||
@@ -50,25 +82,35 @@ export function ForecastMetrics() {
|
||||
},
|
||||
})
|
||||
|
||||
const hasPhaseData = data?.dailyForecastsByPhase && data.dailyForecastsByPhase.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
||||
<CardTitle className="text-xl font-medium">Forecast</CardTitle>
|
||||
<div className="w-[230px]">
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={(range) => {
|
||||
if (range) setDateRange(range);
|
||||
}}
|
||||
future={true}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Target className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[400px]">
|
||||
<ForecastAccuracy />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Tabs value={String(period)} onValueChange={(v) => setPeriod(v === 'year' ? 'year' : Number(v) as Period)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="30">30D</TabsTrigger>
|
||||
<TabsTrigger value="90">90D</TabsTrigger>
|
||||
<TabsTrigger value="year">EOY</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-0 -mb-2">
|
||||
{error ? (
|
||||
<div className="text-sm text-red-500">Error: {error.message}</div>
|
||||
) : isLoading ? (
|
||||
<div className="text-sm">Loading forecast metrics...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -77,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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import config from '../../config';
|
||||
|
||||
interface SalesData {
|
||||
date: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
const { data, isLoading, error } = useQuery<SalesData[]>({
|
||||
queryKey: ['sales-overview'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/sales-overview`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch sales overview');
|
||||
}
|
||||
const rawData = await response.json();
|
||||
return rawData.map((item: SalesData) => ({
|
||||
...item,
|
||||
total: parseFloat(item.total.toString()),
|
||||
date: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading chart...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">Error loading sales overview</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={data}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `$${value.toLocaleString()}`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales']}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="total"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,16 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { ClipboardList, AlertCircle, Truck, DollarSign, Tag } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
interface PurchaseMetricsData {
|
||||
activePurchaseOrders: number // Orders that are not canceled, done, or fully received
|
||||
overduePurchaseOrders: number // Orders past their expected delivery date
|
||||
onOrderUnits: number // Total units across all active orders
|
||||
onOrderCost: number // Total cost across all active orders
|
||||
onOrderRetail: number // Total retail value across all active orders
|
||||
activePurchaseOrders: number
|
||||
overduePurchaseOrders: number
|
||||
onOrderUnits: number
|
||||
onOrderCost: number
|
||||
onOrderRetail: number
|
||||
vendorOrders: {
|
||||
vendor: string
|
||||
orders: number
|
||||
@@ -35,7 +35,6 @@ const COLORS = [
|
||||
const renderActiveShape = (props: any) => {
|
||||
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, vendor, cost } = props;
|
||||
|
||||
// Split vendor name into words and create lines of max 12 chars
|
||||
const words = vendor.split(' ');
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
@@ -52,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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import config from "@/config"
|
||||
|
||||
interface VendorMetrics {
|
||||
vendor: string
|
||||
avg_lead_time: number
|
||||
on_time_delivery_rate: number
|
||||
avg_fill_rate: number
|
||||
total_orders: number
|
||||
active_orders: number
|
||||
overdue_orders: number
|
||||
}
|
||||
|
||||
export function VendorPerformance() {
|
||||
const { data: vendors } = useQuery<VendorMetrics[]>({
|
||||
queryKey: ["vendor-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/vendor/performance`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch vendor metrics")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
|
||||
// Sort vendors by on-time delivery rate
|
||||
const sortedVendors = vendors
|
||||
?.sort((a, b) => b.on_time_delivery_rate - a.on_time_delivery_rate)
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium">Top Vendor Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[400px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>On-Time</TableHead>
|
||||
<TableHead className="text-right">Fill Rate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedVendors?.map((vendor) => (
|
||||
<TableRow key={vendor.vendor}>
|
||||
<TableCell className="font-medium">{vendor.vendor}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={vendor.on_time_delivery_rate}
|
||||
className="h-2"
|
||||
/>
|
||||
<span className="w-10 text-sm">
|
||||
{vendor.on_time_delivery_rate.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{vendor.avg_fill_rate.toFixed(0)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function EditableInput({
|
||||
copyable,
|
||||
alwaysShowCopy,
|
||||
formatDisplay,
|
||||
rightAction,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
@@ -30,6 +31,7 @@ export function EditableInput({
|
||||
copyable?: boolean;
|
||||
alwaysShowCopy?: boolean;
|
||||
formatDisplay?: (val: string) => string;
|
||||
rightAction?: React.ReactNode;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -106,6 +108,7 @@ export function EditableInput({
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{rightAction}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ function SortableImageCell({
|
||||
src={src}
|
||||
alt={`Image ${image.iid}`}
|
||||
className={cn(
|
||||
"w-full h-full object-cover pointer-events-none select-none",
|
||||
"w-full h-full object-contain pointer-events-none select-none",
|
||||
isMain ? "rounded-lg" : "rounded-md"
|
||||
)}
|
||||
draggable={false}
|
||||
|
||||
@@ -7,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'}`);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user