Compare commits
6 Commits
8606a90e34
...
merge-dash
| Author | SHA1 | Date | |
|---|---|---|---|
| 763aa4f74b | |||
| 520ff5bd74 | |||
| 8496bbc4ee | |||
| 38f6688f10 | |||
| fcfe7e2fab | |||
| 2e3e81a02b |
@@ -116,6 +116,7 @@ CREATE TABLE public.product_metrics (
|
||||
-- Lifetime Metrics (Recalculated Hourly/Daily from daily_product_snapshots)
|
||||
lifetime_sales INT,
|
||||
lifetime_revenue NUMERIC(16, 4),
|
||||
lifetime_revenue_quality VARCHAR(10), -- 'exact', 'partial', 'estimated'
|
||||
|
||||
-- First Period Metrics (Calculated Once/Periodically from daily_product_snapshots)
|
||||
first_7_days_sales INT, first_7_days_revenue NUMERIC(14, 4),
|
||||
@@ -176,6 +177,29 @@ CREATE TABLE public.product_metrics (
|
||||
-- Product Status (Calculated from metrics)
|
||||
status VARCHAR, -- Stores status values like: Critical, Reorder Soon, Healthy, Overstock, At Risk, New
|
||||
|
||||
-- Growth Metrics (P3)
|
||||
sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth current 30d vs prev 30d
|
||||
revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth current 30d vs prev 30d
|
||||
sales_growth_yoy NUMERIC(10, 2), -- Year-over-year sales growth %
|
||||
revenue_growth_yoy NUMERIC(10, 2), -- Year-over-year revenue growth %
|
||||
|
||||
-- Demand Variability Metrics (P3)
|
||||
sales_variance_30d NUMERIC(10, 2), -- Variance of daily sales
|
||||
sales_std_dev_30d NUMERIC(10, 2), -- Standard deviation of daily sales
|
||||
sales_cv_30d NUMERIC(10, 2), -- Coefficient of variation
|
||||
demand_pattern VARCHAR(20), -- 'stable', 'variable', 'sporadic', 'lumpy'
|
||||
|
||||
-- Service Level & Fill Rate (P5)
|
||||
fill_rate_30d NUMERIC(8, 2), -- % of demand fulfilled from stock
|
||||
stockout_incidents_30d INT, -- Days with stockouts
|
||||
service_level_30d NUMERIC(8, 2), -- % of days without stockouts
|
||||
lost_sales_incidents_30d INT, -- Days with potential lost sales
|
||||
|
||||
-- Seasonality (P5)
|
||||
seasonality_index NUMERIC(10, 2), -- Current vs average (100 = average)
|
||||
seasonal_pattern VARCHAR(20), -- 'none', 'weekly', 'monthly', 'quarterly', 'yearly'
|
||||
peak_season VARCHAR(20), -- e.g., 'Q4', 'summer', 'holiday'
|
||||
|
||||
CONSTRAINT fk_product_metrics_pid FOREIGN KEY (pid) REFERENCES public.products(pid) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
@@ -242,7 +266,8 @@ CREATE TABLE public.category_metrics (
|
||||
-- Calculated KPIs (Based on 30d aggregates) - Apply to rolled-up metrics
|
||||
avg_margin_30d NUMERIC(7, 3), -- (profit / revenue) * 100
|
||||
stock_turn_30d NUMERIC(10, 3), -- sales_units / avg_stock_units (Needs avg stock calc)
|
||||
-- growth_rate_30d NUMERIC(7, 3), -- (current 30d rev - prev 30d rev) / prev 30d rev
|
||||
sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in sales units
|
||||
revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in revenue
|
||||
|
||||
CONSTRAINT fk_category_metrics_cat_id FOREIGN KEY (category_id) REFERENCES public.categories(cat_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
@@ -280,7 +305,9 @@ CREATE TABLE public.vendor_metrics (
|
||||
lifetime_sales INT NOT NULL DEFAULT 0, lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00,
|
||||
|
||||
-- Calculated KPIs (Based on 30d aggregates)
|
||||
avg_margin_30d NUMERIC(14, 4) -- (profit / revenue) * 100
|
||||
avg_margin_30d NUMERIC(14, 4), -- (profit / revenue) * 100
|
||||
sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in sales units
|
||||
revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in revenue
|
||||
-- Add more KPIs if needed (e.g., avg product value, sell-through rate for vendor)
|
||||
);
|
||||
CREATE INDEX idx_vendor_metrics_active_count ON public.vendor_metrics(active_product_count);
|
||||
@@ -309,7 +336,9 @@ CREATE TABLE public.brand_metrics (
|
||||
lifetime_sales INT NOT NULL DEFAULT 0, lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00,
|
||||
|
||||
-- Calculated KPIs (Based on 30d aggregates)
|
||||
avg_margin_30d NUMERIC(7, 3) -- (profit / revenue) * 100
|
||||
avg_margin_30d NUMERIC(7, 3), -- (profit / revenue) * 100
|
||||
sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in sales units
|
||||
revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in revenue
|
||||
-- Add more KPIs if needed (e.g., avg product value, sell-through rate for brand)
|
||||
);
|
||||
CREATE INDEX idx_brand_metrics_active_count ON public.brand_metrics(active_product_count);
|
||||
@@ -437,7 +437,6 @@ async function executeSqlStep(config, progress) {
|
||||
|
||||
try {
|
||||
// Try executing exactly as individual scripts do
|
||||
console.log('Executing SQL with simple query method...');
|
||||
const result = await connection.query(sqlQuery);
|
||||
|
||||
// Try to extract row count from result
|
||||
|
||||
@@ -42,6 +42,20 @@ BEGIN
|
||||
JOIN public.products p ON pm.pid = p.pid
|
||||
GROUP BY brand_group
|
||||
),
|
||||
PreviousPeriodBrandMetrics AS (
|
||||
-- Get previous period metrics for growth calculation
|
||||
SELECT
|
||||
COALESCE(p.brand, 'Unbranded') AS brand_group,
|
||||
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.products p ON dps.pid = p.pid
|
||||
GROUP BY brand_group
|
||||
),
|
||||
AllBrands AS (
|
||||
-- Ensure all brands from products table are included, mapping NULL/empty to 'Unbranded'
|
||||
SELECT DISTINCT COALESCE(brand, 'Unbranded') as brand_group
|
||||
@@ -53,7 +67,8 @@ BEGIN
|
||||
current_stock_units, current_stock_cost, current_stock_retail,
|
||||
sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d,
|
||||
sales_365d, revenue_365d, lifetime_sales, lifetime_revenue,
|
||||
avg_margin_30d
|
||||
avg_margin_30d,
|
||||
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev
|
||||
)
|
||||
SELECT
|
||||
b.brand_group,
|
||||
@@ -78,9 +93,13 @@ BEGIN
|
||||
-- This is mathematically equivalent to profit/revenue but more explicit
|
||||
((COALESCE(ba.revenue_30d, 0) - COALESCE(ba.cogs_30d, 0)) / COALESCE(ba.revenue_30d, 1)) * 100.0
|
||||
ELSE NULL -- No margin for low/no revenue brands
|
||||
END
|
||||
END,
|
||||
-- Growth metrics
|
||||
std_numeric(safe_divide((ba.sales_30d - ppbm.sales_prev_30d) * 100.0, ppbm.sales_prev_30d), 2),
|
||||
std_numeric(safe_divide((ba.revenue_30d - ppbm.revenue_prev_30d) * 100.0, ppbm.revenue_prev_30d), 2)
|
||||
FROM AllBrands b
|
||||
LEFT JOIN BrandAggregates ba ON b.brand_group = ba.brand_group
|
||||
LEFT JOIN PreviousPeriodBrandMetrics ppbm ON b.brand_group = ppbm.brand_group
|
||||
|
||||
ON CONFLICT (brand_name) DO UPDATE SET
|
||||
last_calculated = EXCLUDED.last_calculated,
|
||||
@@ -95,7 +114,9 @@ BEGIN
|
||||
profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d,
|
||||
sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d,
|
||||
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue,
|
||||
avg_margin_30d = EXCLUDED.avg_margin_30d
|
||||
avg_margin_30d = EXCLUDED.avg_margin_30d,
|
||||
sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev,
|
||||
revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev
|
||||
WHERE -- Only update if at least one value has changed
|
||||
brand_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR
|
||||
brand_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Description: Calculates and updates aggregated metrics per category.
|
||||
-- Dependencies: product_metrics, products, categories, product_categories, calculate_status table.
|
||||
-- Description: Calculates and updates aggregated metrics per category with hierarchy rollups.
|
||||
-- Dependencies: product_metrics, products, categories, product_categories, category_hierarchy, calculate_status table.
|
||||
-- Frequency: Daily (after product_metrics update).
|
||||
|
||||
DO $$
|
||||
@@ -9,55 +9,21 @@ DECLARE
|
||||
_min_revenue NUMERIC := 50.00; -- Minimum revenue threshold for margin calculation
|
||||
BEGIN
|
||||
RAISE NOTICE 'Running % calculation...', _module_name;
|
||||
|
||||
-- Refresh the category hierarchy materialized view first
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY category_hierarchy;
|
||||
|
||||
WITH
|
||||
-- Identify the hierarchy depth for each category
|
||||
CategoryDepth AS (
|
||||
WITH RECURSIVE CategoryTree AS (
|
||||
-- Base case: Start with categories without parents (root categories)
|
||||
SELECT cat_id, name, parent_id, 0 AS depth
|
||||
FROM public.categories
|
||||
WHERE parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive step: Add child categories with incremented depth
|
||||
SELECT c.cat_id, c.name, c.parent_id, ct.depth + 1
|
||||
FROM public.categories c
|
||||
JOIN CategoryTree ct ON c.parent_id = ct.cat_id
|
||||
)
|
||||
SELECT cat_id, depth
|
||||
FROM CategoryTree
|
||||
),
|
||||
-- For each product, find the most specific (deepest) category it belongs to
|
||||
ProductDeepestCategory AS (
|
||||
SELECT
|
||||
pc.pid,
|
||||
pc.cat_id
|
||||
FROM public.product_categories pc
|
||||
JOIN CategoryDepth cd ON pc.cat_id = cd.cat_id
|
||||
-- This is the key part: for each product, select only the category with maximum depth
|
||||
WHERE (pc.pid, cd.depth) IN (
|
||||
SELECT pc2.pid, MAX(cd2.depth)
|
||||
FROM public.product_categories pc2
|
||||
JOIN CategoryDepth cd2 ON pc2.cat_id = cd2.cat_id
|
||||
GROUP BY pc2.pid
|
||||
)
|
||||
),
|
||||
-- Calculate metrics only at the most specific category level for each product
|
||||
-- These are the direct metrics (only products directly in this category)
|
||||
DirectCategoryMetrics AS (
|
||||
-- First calculate direct metrics (products directly in each category)
|
||||
WITH DirectCategoryMetrics AS (
|
||||
SELECT
|
||||
pdc.cat_id,
|
||||
-- Counts
|
||||
pc.cat_id,
|
||||
COUNT(DISTINCT pm.pid) AS product_count,
|
||||
COUNT(DISTINCT CASE WHEN pm.is_visible THEN pm.pid END) AS active_product_count,
|
||||
COUNT(DISTINCT CASE WHEN pm.is_replenishable THEN pm.pid END) AS replenishable_product_count,
|
||||
-- Current Stock
|
||||
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,
|
||||
-- Rolling Periods - Only include products with actual sales in each period
|
||||
-- Sales metrics with proper filtering
|
||||
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,
|
||||
@@ -67,179 +33,141 @@ BEGIN
|
||||
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,
|
||||
-- Data for KPIs - Only average stock for products with stock
|
||||
SUM(CASE WHEN pm.avg_stock_units_30d > 0 THEN pm.avg_stock_units_30d ELSE 0 END) AS total_avg_stock_units_30d
|
||||
FROM public.product_metrics pm
|
||||
JOIN ProductDeepestCategory pdc ON pm.pid = pdc.pid
|
||||
GROUP BY pdc.cat_id
|
||||
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
|
||||
FROM public.product_categories pc
|
||||
JOIN public.product_metrics pm ON pc.pid = pm.pid
|
||||
GROUP BY pc.cat_id
|
||||
),
|
||||
-- Build a category lookup table for parent relationships
|
||||
CategoryHierarchyPaths AS (
|
||||
WITH RECURSIVE ParentPaths AS (
|
||||
-- Base case: All categories with their immediate parents
|
||||
SELECT
|
||||
cat_id,
|
||||
cat_id as leaf_id, -- Every category is its own leaf initially
|
||||
ARRAY[cat_id] as path
|
||||
FROM public.categories
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive step: Walk up the parent chain
|
||||
SELECT
|
||||
c.parent_id as cat_id,
|
||||
pp.leaf_id, -- Keep the original leaf_id
|
||||
c.parent_id || pp.path as path
|
||||
FROM ParentPaths pp
|
||||
JOIN public.categories c ON pp.cat_id = c.cat_id
|
||||
WHERE c.parent_id IS NOT NULL -- Stop at root categories
|
||||
)
|
||||
-- Select distinct paths to avoid duplication
|
||||
SELECT DISTINCT cat_id, leaf_id
|
||||
FROM ParentPaths
|
||||
),
|
||||
-- Aggregate metrics from leaf categories to their ancestors without duplication
|
||||
-- These are the rolled-up metrics (including all child categories)
|
||||
RollupMetrics AS (
|
||||
-- Calculate rolled-up metrics (including all descendant categories)
|
||||
RolledUpMetrics AS (
|
||||
SELECT
|
||||
chp.cat_id,
|
||||
-- For each parent category, count distinct products to avoid duplication
|
||||
COUNT(DISTINCT dcm.cat_id) AS child_categories_count,
|
||||
SUM(dcm.product_count) AS rollup_product_count,
|
||||
SUM(dcm.active_product_count) AS rollup_active_product_count,
|
||||
SUM(dcm.replenishable_product_count) AS rollup_replenishable_product_count,
|
||||
SUM(dcm.current_stock_units) AS rollup_current_stock_units,
|
||||
SUM(dcm.current_stock_cost) AS rollup_current_stock_cost,
|
||||
SUM(dcm.current_stock_retail) AS rollup_current_stock_retail,
|
||||
SUM(dcm.sales_7d) AS rollup_sales_7d,
|
||||
SUM(dcm.revenue_7d) AS rollup_revenue_7d,
|
||||
SUM(dcm.sales_30d) AS rollup_sales_30d,
|
||||
SUM(dcm.revenue_30d) AS rollup_revenue_30d,
|
||||
SUM(dcm.cogs_30d) AS rollup_cogs_30d,
|
||||
SUM(dcm.profit_30d) AS rollup_profit_30d,
|
||||
SUM(dcm.sales_365d) AS rollup_sales_365d,
|
||||
SUM(dcm.revenue_365d) AS rollup_revenue_365d,
|
||||
SUM(dcm.lifetime_sales) AS rollup_lifetime_sales,
|
||||
SUM(dcm.lifetime_revenue) AS rollup_lifetime_revenue,
|
||||
SUM(dcm.total_avg_stock_units_30d) AS rollup_total_avg_stock_units_30d
|
||||
FROM CategoryHierarchyPaths chp
|
||||
JOIN DirectCategoryMetrics dcm ON chp.leaf_id = dcm.cat_id
|
||||
GROUP BY chp.cat_id
|
||||
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
|
||||
),
|
||||
-- Combine direct and rollup metrics
|
||||
CombinedMetrics AS (
|
||||
PreviousPeriodCategoryMetrics AS (
|
||||
-- Get previous period metrics for growth calculation
|
||||
SELECT
|
||||
pc.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
|
||||
),
|
||||
AllCategories AS (
|
||||
-- Ensure all categories are included
|
||||
SELECT
|
||||
c.cat_id,
|
||||
c.name,
|
||||
c.type,
|
||||
c.parent_id,
|
||||
-- Direct metrics (just this category)
|
||||
COALESCE(dcm.product_count, 0) AS direct_product_count,
|
||||
COALESCE(dcm.active_product_count, 0) AS direct_active_product_count,
|
||||
COALESCE(dcm.replenishable_product_count, 0) AS direct_replenishable_product_count,
|
||||
COALESCE(dcm.current_stock_units, 0) AS direct_current_stock_units,
|
||||
COALESCE(dcm.current_stock_cost, 0) AS direct_current_stock_cost,
|
||||
COALESCE(dcm.current_stock_retail, 0) AS direct_current_stock_retail,
|
||||
COALESCE(dcm.sales_7d, 0) AS direct_sales_7d,
|
||||
COALESCE(dcm.revenue_7d, 0) AS direct_revenue_7d,
|
||||
COALESCE(dcm.sales_30d, 0) AS direct_sales_30d,
|
||||
COALESCE(dcm.revenue_30d, 0) AS direct_revenue_30d,
|
||||
COALESCE(dcm.cogs_30d, 0) AS direct_cogs_30d,
|
||||
COALESCE(dcm.profit_30d, 0) AS direct_profit_30d,
|
||||
COALESCE(dcm.sales_365d, 0) AS direct_sales_365d,
|
||||
COALESCE(dcm.revenue_365d, 0) AS direct_revenue_365d,
|
||||
COALESCE(dcm.lifetime_sales, 0) AS direct_lifetime_sales,
|
||||
COALESCE(dcm.lifetime_revenue, 0) AS direct_lifetime_revenue,
|
||||
COALESCE(dcm.total_avg_stock_units_30d, 0) AS direct_avg_stock_units_30d,
|
||||
|
||||
-- Rolled up metrics (this category + all children)
|
||||
COALESCE(rm.rollup_product_count, 0) AS product_count,
|
||||
COALESCE(rm.rollup_active_product_count, 0) AS active_product_count,
|
||||
COALESCE(rm.rollup_replenishable_product_count, 0) AS replenishable_product_count,
|
||||
COALESCE(rm.rollup_current_stock_units, 0) AS current_stock_units,
|
||||
COALESCE(rm.rollup_current_stock_cost, 0) AS current_stock_cost,
|
||||
COALESCE(rm.rollup_current_stock_retail, 0) AS current_stock_retail,
|
||||
COALESCE(rm.rollup_sales_7d, 0) AS sales_7d,
|
||||
COALESCE(rm.rollup_revenue_7d, 0) AS revenue_7d,
|
||||
COALESCE(rm.rollup_sales_30d, 0) AS sales_30d,
|
||||
COALESCE(rm.rollup_revenue_30d, 0) AS revenue_30d,
|
||||
COALESCE(rm.rollup_cogs_30d, 0) AS cogs_30d,
|
||||
COALESCE(rm.rollup_profit_30d, 0) AS profit_30d,
|
||||
COALESCE(rm.rollup_sales_365d, 0) AS sales_365d,
|
||||
COALESCE(rm.rollup_revenue_365d, 0) AS revenue_365d,
|
||||
COALESCE(rm.rollup_lifetime_sales, 0) AS lifetime_sales,
|
||||
COALESCE(rm.rollup_lifetime_revenue, 0) AS lifetime_revenue,
|
||||
COALESCE(rm.rollup_total_avg_stock_units_30d, 0) AS total_avg_stock_units_30d
|
||||
c.parent_id
|
||||
FROM public.categories c
|
||||
LEFT JOIN DirectCategoryMetrics dcm ON c.cat_id = dcm.cat_id
|
||||
LEFT JOIN RollupMetrics rm ON c.cat_id = rm.cat_id
|
||||
WHERE c.status = 'active'
|
||||
)
|
||||
INSERT INTO public.category_metrics (
|
||||
category_id, category_name, category_type, parent_id, last_calculated,
|
||||
-- Store all direct and rolled up metrics
|
||||
-- Rolled-up metrics
|
||||
product_count, active_product_count, replenishable_product_count,
|
||||
current_stock_units, current_stock_cost, current_stock_retail,
|
||||
sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d,
|
||||
sales_365d, revenue_365d, lifetime_sales, lifetime_revenue,
|
||||
-- Also store direct metrics with direct_ prefix
|
||||
-- Direct metrics
|
||||
direct_product_count, direct_active_product_count, direct_replenishable_product_count,
|
||||
direct_current_stock_units, direct_stock_cost, direct_stock_retail,
|
||||
direct_sales_7d, direct_revenue_7d, direct_sales_30d, direct_revenue_30d,
|
||||
direct_sales_7d, direct_revenue_7d, direct_sales_30d, direct_revenue_30d,
|
||||
direct_profit_30d, direct_cogs_30d, direct_sales_365d, direct_revenue_365d,
|
||||
direct_lifetime_sales, direct_lifetime_revenue,
|
||||
-- KPIs
|
||||
avg_margin_30d, stock_turn_30d
|
||||
avg_margin_30d,
|
||||
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev
|
||||
)
|
||||
SELECT
|
||||
cm.cat_id,
|
||||
cm.name,
|
||||
cm.type,
|
||||
cm.parent_id,
|
||||
ac.cat_id,
|
||||
ac.name,
|
||||
ac.type,
|
||||
ac.parent_id,
|
||||
_start_time,
|
||||
-- Rolled-up metrics (total including children)
|
||||
cm.product_count,
|
||||
cm.active_product_count,
|
||||
cm.replenishable_product_count,
|
||||
cm.current_stock_units,
|
||||
cm.current_stock_cost,
|
||||
cm.current_stock_retail,
|
||||
cm.sales_7d, cm.revenue_7d,
|
||||
cm.sales_30d, cm.revenue_30d, cm.profit_30d, cm.cogs_30d,
|
||||
cm.sales_365d, cm.revenue_365d,
|
||||
cm.lifetime_sales, cm.lifetime_revenue,
|
||||
-- Direct metrics (just this category)
|
||||
cm.direct_product_count,
|
||||
cm.direct_active_product_count,
|
||||
cm.direct_replenishable_product_count,
|
||||
cm.direct_current_stock_units,
|
||||
cm.direct_current_stock_cost,
|
||||
cm.direct_current_stock_retail,
|
||||
cm.direct_sales_7d, cm.direct_revenue_7d,
|
||||
cm.direct_sales_30d, cm.direct_revenue_30d, cm.direct_profit_30d, cm.direct_cogs_30d,
|
||||
cm.direct_sales_365d, cm.direct_revenue_365d,
|
||||
cm.direct_lifetime_sales, cm.direct_lifetime_revenue,
|
||||
-- Rolled-up metrics (includes descendants)
|
||||
COALESCE(rum.product_count, 0),
|
||||
COALESCE(rum.active_product_count, 0),
|
||||
COALESCE(rum.replenishable_product_count, 0),
|
||||
COALESCE(rum.current_stock_units, 0),
|
||||
COALESCE(rum.current_stock_cost, 0.00),
|
||||
COALESCE(rum.current_stock_retail, 0.00),
|
||||
COALESCE(rum.sales_7d, 0), COALESCE(rum.revenue_7d, 0.00),
|
||||
COALESCE(rum.sales_30d, 0), COALESCE(rum.revenue_30d, 0.00),
|
||||
COALESCE(rum.profit_30d, 0.00), COALESCE(rum.cogs_30d, 0.00),
|
||||
COALESCE(rum.sales_365d, 0), COALESCE(rum.revenue_365d, 0.00),
|
||||
COALESCE(rum.lifetime_sales, 0), COALESCE(rum.lifetime_revenue, 0.00),
|
||||
-- Direct metrics (only this category)
|
||||
COALESCE(dcm.product_count, 0),
|
||||
COALESCE(dcm.active_product_count, 0),
|
||||
COALESCE(dcm.replenishable_product_count, 0),
|
||||
COALESCE(dcm.current_stock_units, 0),
|
||||
COALESCE(dcm.current_stock_cost, 0.00),
|
||||
COALESCE(dcm.current_stock_retail, 0.00),
|
||||
COALESCE(dcm.sales_7d, 0), COALESCE(dcm.revenue_7d, 0.00),
|
||||
COALESCE(dcm.sales_30d, 0), COALESCE(dcm.revenue_30d, 0.00),
|
||||
COALESCE(dcm.profit_30d, 0.00), COALESCE(dcm.cogs_30d, 0.00),
|
||||
COALESCE(dcm.sales_365d, 0), COALESCE(dcm.revenue_365d, 0.00),
|
||||
COALESCE(dcm.lifetime_sales, 0), COALESCE(dcm.lifetime_revenue, 0.00),
|
||||
-- KPIs - Calculate margin only for categories with significant revenue
|
||||
CASE
|
||||
WHEN cm.revenue_30d >= _min_revenue THEN
|
||||
((cm.revenue_30d - cm.cogs_30d) / cm.revenue_30d) * 100.0
|
||||
ELSE NULL -- No margin for low/no revenue categories
|
||||
WHEN COALESCE(rum.revenue_30d, 0) >= _min_revenue THEN
|
||||
((COALESCE(rum.revenue_30d, 0) - COALESCE(rum.cogs_30d, 0)) / COALESCE(rum.revenue_30d, 1)) * 100.0
|
||||
ELSE NULL
|
||||
END,
|
||||
-- Stock Turn calculation
|
||||
CASE
|
||||
WHEN cm.total_avg_stock_units_30d > 0 THEN
|
||||
cm.sales_30d / cm.total_avg_stock_units_30d
|
||||
ELSE NULL -- No stock turn if no average stock
|
||||
END
|
||||
FROM CombinedMetrics cm
|
||||
-- Growth metrics for rolled-up values
|
||||
std_numeric(safe_divide((rum.sales_30d - rupp.sales_prev_30d) * 100.0, rupp.sales_prev_30d), 2),
|
||||
std_numeric(safe_divide((rum.revenue_30d - rupp.revenue_prev_30d) * 100.0, rupp.revenue_prev_30d), 2)
|
||||
FROM AllCategories ac
|
||||
LEFT JOIN DirectCategoryMetrics dcm ON ac.cat_id = dcm.cat_id
|
||||
LEFT JOIN RolledUpMetrics rum ON ac.cat_id = rum.cat_id
|
||||
LEFT JOIN RolledUpPreviousPeriod rupp ON ac.cat_id = rupp.cat_id
|
||||
|
||||
ON CONFLICT (category_id) DO UPDATE SET
|
||||
last_calculated = EXCLUDED.last_calculated,
|
||||
category_name = EXCLUDED.category_name,
|
||||
category_type = EXCLUDED.category_type,
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
last_calculated = EXCLUDED.last_calculated,
|
||||
|
||||
-- ROLLED-UP METRICS (includes this category + all descendants)
|
||||
-- Rolled-up metrics
|
||||
product_count = EXCLUDED.product_count,
|
||||
active_product_count = EXCLUDED.active_product_count,
|
||||
replenishable_product_count = EXCLUDED.replenishable_product_count,
|
||||
@@ -251,8 +179,7 @@ BEGIN
|
||||
profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d,
|
||||
sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d,
|
||||
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue,
|
||||
|
||||
-- DIRECT METRICS (only products directly in this category)
|
||||
-- Direct metrics
|
||||
direct_product_count = EXCLUDED.direct_product_count,
|
||||
direct_active_product_count = EXCLUDED.direct_active_product_count,
|
||||
direct_replenishable_product_count = EXCLUDED.direct_replenishable_product_count,
|
||||
@@ -264,10 +191,9 @@ BEGIN
|
||||
direct_profit_30d = EXCLUDED.direct_profit_30d, direct_cogs_30d = EXCLUDED.direct_cogs_30d,
|
||||
direct_sales_365d = EXCLUDED.direct_sales_365d, direct_revenue_365d = EXCLUDED.direct_revenue_365d,
|
||||
direct_lifetime_sales = EXCLUDED.direct_lifetime_sales, direct_lifetime_revenue = EXCLUDED.direct_lifetime_revenue,
|
||||
|
||||
-- Calculated KPIs
|
||||
avg_margin_30d = EXCLUDED.avg_margin_30d,
|
||||
stock_turn_30d = EXCLUDED.stock_turn_30d
|
||||
sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev,
|
||||
revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev
|
||||
WHERE -- Only update if at least one value has changed
|
||||
category_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR
|
||||
category_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR
|
||||
@@ -291,19 +217,23 @@ WITH update_stats AS (
|
||||
SELECT
|
||||
COUNT(*) as total_categories,
|
||||
COUNT(*) FILTER (WHERE last_calculated >= NOW() - INTERVAL '5 minutes') as rows_processed,
|
||||
COUNT(*) FILTER (WHERE category_type = 11) as main_categories, -- 11 = category
|
||||
COUNT(*) FILTER (WHERE category_type = 12) as subcategories, -- 12 = subcategory
|
||||
SUM(product_count) as total_products,
|
||||
SUM(active_product_count) as total_active_products,
|
||||
SUM(current_stock_units) as total_stock_units
|
||||
COUNT(*) FILTER (WHERE category_type = 10) as sections,
|
||||
COUNT(*) FILTER (WHERE category_type = 11) as categories,
|
||||
COUNT(*) FILTER (WHERE category_type = 12) as subcategories,
|
||||
SUM(product_count) as total_products_rolled,
|
||||
SUM(direct_product_count) as total_products_direct,
|
||||
SUM(sales_30d) as total_sales_30d,
|
||||
SUM(revenue_30d) as total_revenue_30d
|
||||
FROM public.category_metrics
|
||||
)
|
||||
SELECT
|
||||
rows_processed,
|
||||
total_categories,
|
||||
main_categories,
|
||||
sections,
|
||||
categories,
|
||||
subcategories,
|
||||
total_products::int,
|
||||
total_active_products::int,
|
||||
total_stock_units::int
|
||||
total_products_rolled::int,
|
||||
total_products_direct::int,
|
||||
total_sales_30d::int,
|
||||
ROUND(total_revenue_30d, 2) as total_revenue_30d
|
||||
FROM update_stats;
|
||||
@@ -44,6 +44,21 @@ BEGIN
|
||||
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
|
||||
GROUP BY p.vendor
|
||||
),
|
||||
PreviousPeriodVendorMetrics AS (
|
||||
-- Get previous period metrics for growth calculation
|
||||
SELECT
|
||||
p.vendor,
|
||||
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.products p ON dps.pid = p.pid
|
||||
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
|
||||
GROUP BY p.vendor
|
||||
),
|
||||
VendorPOAggregates AS (
|
||||
-- Aggregate PO related stats including lead time calculated from POs to receivings
|
||||
SELECT
|
||||
@@ -78,7 +93,8 @@ BEGIN
|
||||
po_count_365d, avg_lead_time_days,
|
||||
sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d,
|
||||
sales_365d, revenue_365d, lifetime_sales, lifetime_revenue,
|
||||
avg_margin_30d
|
||||
avg_margin_30d,
|
||||
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev
|
||||
)
|
||||
SELECT
|
||||
v.vendor,
|
||||
@@ -102,10 +118,14 @@ BEGIN
|
||||
COALESCE(vpa.sales_365d, 0), COALESCE(vpa.revenue_365d, 0.00),
|
||||
COALESCE(vpa.lifetime_sales, 0), COALESCE(vpa.lifetime_revenue, 0.00),
|
||||
-- KPIs
|
||||
(vpa.profit_30d / NULLIF(vpa.revenue_30d, 0)) * 100.0
|
||||
(vpa.profit_30d / NULLIF(vpa.revenue_30d, 0)) * 100.0,
|
||||
-- Growth metrics
|
||||
std_numeric(safe_divide((vpa.sales_30d - ppvm.sales_prev_30d) * 100.0, ppvm.sales_prev_30d), 2),
|
||||
std_numeric(safe_divide((vpa.revenue_30d - ppvm.revenue_prev_30d) * 100.0, ppvm.revenue_prev_30d), 2)
|
||||
FROM AllVendors v
|
||||
LEFT JOIN VendorProductAggregates vpa ON v.vendor = vpa.vendor
|
||||
LEFT JOIN VendorPOAggregates vpoa ON v.vendor = vpoa.vendor
|
||||
LEFT JOIN PreviousPeriodVendorMetrics ppvm ON v.vendor = ppvm.vendor
|
||||
|
||||
ON CONFLICT (vendor_name) DO UPDATE SET
|
||||
last_calculated = EXCLUDED.last_calculated,
|
||||
@@ -124,7 +144,9 @@ BEGIN
|
||||
profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d,
|
||||
sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d,
|
||||
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue,
|
||||
avg_margin_30d = EXCLUDED.avg_margin_30d
|
||||
avg_margin_30d = EXCLUDED.avg_margin_30d,
|
||||
sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev,
|
||||
revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev
|
||||
WHERE -- Only update if at least one value has changed
|
||||
vendor_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR
|
||||
vendor_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR
|
||||
|
||||
@@ -86,7 +86,14 @@ 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, -- Before discount
|
||||
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, -- 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,
|
||||
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, -- Use current regular price for simplicity here
|
||||
|
||||
-- Aggregate Returns (Quantity < 0 or Status = Returned)
|
||||
|
||||
@@ -171,6 +171,85 @@ BEGIN
|
||||
FROM public.products p
|
||||
LEFT JOIN public.settings_product sp ON p.pid = sp.pid
|
||||
LEFT JOIN public.settings_vendor sv ON p.vendor = sv.vendor
|
||||
),
|
||||
LifetimeRevenue AS (
|
||||
-- Calculate actual revenue from orders table
|
||||
SELECT
|
||||
o.pid,
|
||||
SUM(o.price * o.quantity - COALESCE(o.discount, 0)) AS lifetime_revenue_from_orders,
|
||||
SUM(o.quantity) AS lifetime_units_from_orders
|
||||
FROM public.orders o
|
||||
WHERE o.status NOT IN ('canceled', 'returned')
|
||||
AND o.quantity > 0
|
||||
GROUP BY o.pid
|
||||
),
|
||||
PreviousPeriodMetrics AS (
|
||||
-- Calculate metrics for previous 30-day period for growth comparison
|
||||
SELECT
|
||||
pid,
|
||||
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '59 days'
|
||||
AND snapshot_date < _current_date - INTERVAL '29 days'
|
||||
THEN units_sold ELSE 0 END) AS sales_prev_30d,
|
||||
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '59 days'
|
||||
AND snapshot_date < _current_date - INTERVAL '29 days'
|
||||
THEN net_revenue ELSE 0 END) AS revenue_prev_30d,
|
||||
-- Year-over-year comparison
|
||||
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '395 days'
|
||||
AND snapshot_date < _current_date - INTERVAL '365 days'
|
||||
THEN units_sold ELSE 0 END) AS sales_30d_last_year,
|
||||
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '395 days'
|
||||
AND snapshot_date < _current_date - INTERVAL '365 days'
|
||||
THEN net_revenue ELSE 0 END) AS revenue_30d_last_year
|
||||
FROM public.daily_product_snapshots
|
||||
GROUP BY pid
|
||||
),
|
||||
DemandVariability AS (
|
||||
-- Calculate variance and standard deviation of daily sales
|
||||
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
|
||||
END AS sales_cv
|
||||
FROM public.daily_product_snapshots
|
||||
WHERE snapshot_date >= _current_date - INTERVAL '29 days'
|
||||
AND snapshot_date <= _current_date
|
||||
GROUP BY pid
|
||||
),
|
||||
ServiceLevels AS (
|
||||
-- Calculate service level and fill rate metrics
|
||||
SELECT
|
||||
pid,
|
||||
COUNT(*) FILTER (WHERE stockout_flag = true) AS stockout_incidents_30d,
|
||||
COUNT(*) FILTER (WHERE stockout_flag = true AND units_sold > 0) AS lost_sales_incidents_30d,
|
||||
-- Service level: percentage of days without stockouts
|
||||
(1.0 - (COUNT(*) FILTER (WHERE stockout_flag = true)::NUMERIC / NULLIF(COUNT(*), 0))) * 100 AS service_level_30d,
|
||||
-- Fill rate: units sold / (units sold + potential lost sales)
|
||||
CASE
|
||||
WHEN SUM(units_sold) > 0 THEN
|
||||
(SUM(units_sold)::NUMERIC /
|
||||
(SUM(units_sold) + SUM(CASE WHEN stockout_flag THEN units_sold * 0.2 ELSE 0 END))) * 100
|
||||
ELSE NULL
|
||||
END AS fill_rate_30d
|
||||
FROM public.daily_product_snapshots
|
||||
WHERE snapshot_date >= _current_date - INTERVAL '29 days'
|
||||
AND snapshot_date <= _current_date
|
||||
GROUP BY pid
|
||||
),
|
||||
SeasonalityAnalysis AS (
|
||||
-- Simple seasonality detection
|
||||
SELECT
|
||||
p.pid,
|
||||
sp.seasonal_pattern,
|
||||
sp.seasonality_index,
|
||||
sp.peak_season
|
||||
FROM products p
|
||||
CROSS JOIN LATERAL detect_seasonal_pattern(p.pid) sp
|
||||
)
|
||||
-- Final UPSERT into product_metrics
|
||||
INSERT INTO public.product_metrics (
|
||||
@@ -187,7 +266,7 @@ BEGIN
|
||||
stockout_days_30d, sales_365d, revenue_365d,
|
||||
avg_stock_units_30d, avg_stock_cost_30d, avg_stock_retail_30d, avg_stock_gross_30d,
|
||||
received_qty_30d, received_cost_30d,
|
||||
lifetime_sales, lifetime_revenue,
|
||||
lifetime_sales, lifetime_revenue, lifetime_revenue_quality,
|
||||
first_7_days_sales, first_7_days_revenue, first_30_days_sales, first_30_days_revenue,
|
||||
first_60_days_sales, first_60_days_revenue, first_90_days_sales, first_90_days_revenue,
|
||||
asp_30d, acp_30d, avg_ros_30d, avg_sales_per_day_30d, avg_sales_per_month_30d,
|
||||
@@ -203,7 +282,13 @@ BEGIN
|
||||
stock_cover_in_days, po_cover_in_days, sells_out_in_days, replenish_date,
|
||||
overstocked_units, overstocked_cost, overstocked_retail, is_old_stock,
|
||||
yesterday_sales,
|
||||
status -- Add status field for calculated status
|
||||
status, -- Add status field for calculated status
|
||||
-- New fields
|
||||
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev,
|
||||
sales_growth_yoy, revenue_growth_yoy,
|
||||
sales_variance_30d, sales_std_dev_30d, sales_cv_30d, demand_pattern,
|
||||
fill_rate_30d, stockout_incidents_30d, service_level_30d, lost_sales_incidents_30d,
|
||||
seasonality_index, seasonal_pattern, peak_season
|
||||
)
|
||||
SELECT
|
||||
ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.is_replenishable,
|
||||
@@ -227,27 +312,33 @@ BEGIN
|
||||
sa.received_qty_30d, sa.received_cost_30d,
|
||||
-- Use total_sold from products table as the source of truth for lifetime sales
|
||||
-- This includes all historical data from the production database
|
||||
ci.historical_total_sold AS lifetime_sales,
|
||||
COALESCE(
|
||||
-- Option 1: Use 30-day average price if available
|
||||
CASE WHEN sa.sales_30d > 0 THEN
|
||||
ci.historical_total_sold * (sa.revenue_30d / NULLIF(sa.sales_30d, 0))
|
||||
ELSE NULL END,
|
||||
-- Option 2: Try 365-day average price if available
|
||||
CASE WHEN sa.sales_365d > 0 THEN
|
||||
ci.historical_total_sold * (sa.revenue_365d / NULLIF(sa.sales_365d, 0))
|
||||
ELSE NULL END,
|
||||
-- Option 3: Use current price as a reasonable estimate
|
||||
ci.historical_total_sold * ci.current_price,
|
||||
-- Option 4: Use regular price if current price might be zero
|
||||
ci.historical_total_sold * ci.current_regular_price,
|
||||
-- Final fallback: Use accumulated revenue (this is less accurate for old products)
|
||||
sa.total_net_revenue
|
||||
) AS lifetime_revenue,
|
||||
ci.historical_total_sold AS lifetime_sales,
|
||||
-- Calculate lifetime revenue using actual historical prices where available
|
||||
CASE
|
||||
WHEN lr.lifetime_revenue_from_orders IS NOT NULL THEN
|
||||
-- We have some order history - use it plus estimate for remaining
|
||||
lr.lifetime_revenue_from_orders +
|
||||
(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)
|
||||
FROM daily_product_snapshots
|
||||
WHERE pid = ci.pid AND sales_7d > 0
|
||||
ORDER BY snapshot_date ASC
|
||||
LIMIT 1),
|
||||
ci.current_price
|
||||
))
|
||||
ELSE
|
||||
-- No order history - estimate using current price
|
||||
ci.historical_total_sold * ci.current_price
|
||||
END AS lifetime_revenue,
|
||||
CASE
|
||||
WHEN lr.lifetime_units_from_orders >= ci.historical_total_sold * 0.9 THEN 'exact'
|
||||
WHEN lr.lifetime_units_from_orders >= ci.historical_total_sold * 0.5 THEN 'partial'
|
||||
ELSE 'estimated'
|
||||
END AS lifetime_revenue_quality,
|
||||
fpm.first_7_days_sales, fpm.first_7_days_revenue, fpm.first_30_days_sales, fpm.first_30_days_revenue,
|
||||
fpm.first_60_days_sales, fpm.first_60_days_revenue, fpm.first_90_days_sales, fpm.first_90_days_revenue,
|
||||
|
||||
-- Calculated KPIs
|
||||
sa.revenue_30d / NULLIF(sa.sales_30d, 0) AS asp_30d,
|
||||
sa.cogs_30d / NULLIF(sa.sales_30d, 0) AS acp_30d,
|
||||
sa.profit_30d / NULLIF(sa.sales_30d, 0) AS avg_ros_30d,
|
||||
@@ -262,317 +353,59 @@ 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,
|
||||
(sa.sales_30d / NULLIF(ci.current_stock + sa.sales_30d, 0)) * 100 AS sell_through_30d,
|
||||
-- Fix sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received)
|
||||
-- Approximating beginning inventory as current stock + units sold - units received
|
||||
(sa.sales_30d / NULLIF(
|
||||
ci.current_stock + sa.sales_30d + sa.returns_units_30d - sa.received_qty_30d,
|
||||
0
|
||||
)) * 100 AS sell_through_30d,
|
||||
|
||||
-- Forecasting intermediate values
|
||||
-- CRITICAL FIX: Use safer velocity calculation to prevent extreme values
|
||||
-- Original problematic calculation: (sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0))
|
||||
-- Use available days (not stockout days) as denominator with a minimum safety value
|
||||
(sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d, -- Standard calculation
|
||||
CASE
|
||||
WHEN sa.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
|
||||
)
|
||||
) AS sales_velocity_daily,
|
||||
-- Use the calculate_sales_velocity function instead of repetitive calculation
|
||||
calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) AS sales_velocity_daily,
|
||||
s.effective_lead_time AS config_lead_time,
|
||||
s.effective_days_of_stock AS config_days_of_stock,
|
||||
s.effective_safety_stock AS config_safety_stock,
|
||||
(s.effective_lead_time + s.effective_days_of_stock) AS planning_period_days,
|
||||
|
||||
-- Apply the same fix to all derived calculations
|
||||
(sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time AS lead_time_forecast_units,
|
||||
calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time AS lead_time_forecast_units,
|
||||
|
||||
(sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_days_of_stock AS days_of_stock_forecast_units,
|
||||
calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock AS days_of_stock_forecast_units,
|
||||
|
||||
(sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * (s.effective_lead_time + s.effective_days_of_stock) AS planning_period_forecast_units,
|
||||
calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * (s.effective_lead_time + s.effective_days_of_stock) AS planning_period_forecast_units,
|
||||
|
||||
(ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time)) AS lead_time_closing_stock,
|
||||
(ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time)) AS lead_time_closing_stock,
|
||||
|
||||
((ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time))) - ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_days_of_stock) AS days_of_stock_closing_stock,
|
||||
((ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time))) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock) AS days_of_stock_closing_stock,
|
||||
|
||||
(((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time) + ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) AS replenishment_needed_raw,
|
||||
((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) AS replenishment_needed_raw,
|
||||
|
||||
-- Final Forecasting / Replenishment Metrics (apply CEILING/GREATEST/etc.)
|
||||
-- Note: These calculations are nested for clarity, can be simplified in prod
|
||||
CEILING(GREATEST(0, ((((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time) + ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS replenishment_units,
|
||||
(CEILING(GREATEST(0, ((((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time) + ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_effective_cost AS replenishment_cost,
|
||||
(CEILING(GREATEST(0, ((((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time) + ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_price AS replenishment_retail,
|
||||
(CEILING(GREATEST(0, ((((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time) + ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * (ci.current_price - ci.current_effective_cost) AS replenishment_profit,
|
||||
-- Final Forecasting / Replenishment Metrics
|
||||
CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS replenishment_units,
|
||||
(CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_effective_cost AS replenishment_cost,
|
||||
(CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_price AS replenishment_retail,
|
||||
(CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * (ci.current_price - ci.current_effective_cost) AS replenishment_profit,
|
||||
|
||||
-- Placeholder for To Order (Apply MOQ/UOM logic here if needed, otherwise equals replenishment)
|
||||
CEILING(GREATEST(0, ((((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time) + ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS to_order_units,
|
||||
-- To Order (Apply MOQ/UOM logic here if needed, otherwise equals replenishment)
|
||||
CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS to_order_units,
|
||||
|
||||
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time))) AS forecast_lost_sales_units,
|
||||
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time))) * ci.current_price AS forecast_lost_revenue,
|
||||
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time))) AS forecast_lost_sales_units,
|
||||
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time))) * ci.current_price AS forecast_lost_revenue,
|
||||
|
||||
ci.current_stock / NULLIF((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
), 0) AS stock_cover_in_days,
|
||||
COALESCE(ooi.on_order_qty, 0) / NULLIF((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
), 0) AS po_cover_in_days,
|
||||
(ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
), 0) AS sells_out_in_days,
|
||||
ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0) AS stock_cover_in_days,
|
||||
COALESCE(ooi.on_order_qty, 0) / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0) AS po_cover_in_days,
|
||||
(ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0) AS sells_out_in_days,
|
||||
|
||||
-- Replenish Date: Date when stock is projected to hit safety stock, minus lead time
|
||||
CASE
|
||||
WHEN (sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) > 0
|
||||
THEN _current_date + FLOOR(GREATEST(0, ci.current_stock - s.effective_safety_stock) / (sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
))::int - s.effective_lead_time
|
||||
WHEN calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) > 0
|
||||
THEN _current_date + FLOOR(GREATEST(0, ci.current_stock - s.effective_safety_stock) / calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int))::int - s.effective_lead_time
|
||||
ELSE NULL
|
||||
END AS replenish_date,
|
||||
|
||||
GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time) + ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_days_of_stock)))::int AS overstocked_units,
|
||||
(GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time) + ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_days_of_stock)))) * ci.current_effective_cost AS overstocked_cost,
|
||||
(GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time) + ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_days_of_stock)))) * ci.current_price AS overstocked_retail,
|
||||
GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)))::int AS overstocked_units,
|
||||
(GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)))) * ci.current_effective_cost AS overstocked_cost,
|
||||
(GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)))) * ci.current_price AS overstocked_retail,
|
||||
|
||||
-- Old Stock Flag
|
||||
(ci.created_at::date < _current_date - INTERVAL '60 day') AND
|
||||
@@ -592,66 +425,18 @@ BEGIN
|
||||
ELSE
|
||||
CASE
|
||||
-- Check for overstock first
|
||||
WHEN GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_lead_time) + ((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
) * s.effective_days_of_stock))) > 0 THEN 'Overstock'
|
||||
WHEN GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock))) > 0 THEN 'Overstock'
|
||||
|
||||
-- Check for Critical stock
|
||||
WHEN ci.current_stock <= 0 OR
|
||||
(ci.current_stock / NULLIF((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
), 0)) <= 0 THEN 'Critical'
|
||||
(ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) <= 0 THEN 'Critical'
|
||||
|
||||
WHEN (ci.current_stock / NULLIF((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical'
|
||||
WHEN (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical'
|
||||
|
||||
-- Check for reorder soon
|
||||
WHEN ((ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
), 0)) < (COALESCE(s.effective_lead_time, 30) + 7) THEN
|
||||
WHEN ((ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) < (COALESCE(s.effective_lead_time, 30) + 7) THEN
|
||||
CASE
|
||||
WHEN (ci.current_stock / NULLIF((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical'
|
||||
WHEN (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical'
|
||||
ELSE 'Reorder Soon'
|
||||
END
|
||||
|
||||
@@ -672,15 +457,7 @@ BEGIN
|
||||
END) > 180 THEN 'At Risk'
|
||||
|
||||
-- Very high stock cover is at risk too
|
||||
WHEN (ci.current_stock / NULLIF((sa.sales_30d /
|
||||
NULLIF(
|
||||
GREATEST(
|
||||
30.0 - sa.stockout_days_30d,
|
||||
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
|
||||
),
|
||||
0
|
||||
)
|
||||
), 0)) > 365 THEN 'At Risk'
|
||||
WHEN (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) > 365 THEN 'At Risk'
|
||||
|
||||
-- New products (less than 30 days old)
|
||||
WHEN (CASE
|
||||
@@ -693,7 +470,30 @@ BEGIN
|
||||
-- If none of the above, assume Healthy
|
||||
ELSE 'Healthy'
|
||||
END
|
||||
END AS status
|
||||
END AS status,
|
||||
|
||||
-- Growth Metrics (P3) - using safe_divide and std_numeric for consistency
|
||||
std_numeric(safe_divide((sa.sales_30d - ppm.sales_prev_30d) * 100.0, ppm.sales_prev_30d), 2) AS sales_growth_30d_vs_prev,
|
||||
std_numeric(safe_divide((sa.revenue_30d - ppm.revenue_prev_30d) * 100.0, ppm.revenue_prev_30d), 2) AS revenue_growth_30d_vs_prev,
|
||||
std_numeric(safe_divide((sa.sales_30d - ppm.sales_30d_last_year) * 100.0, ppm.sales_30d_last_year), 2) AS sales_growth_yoy,
|
||||
std_numeric(safe_divide((sa.revenue_30d - ppm.revenue_30d_last_year) * 100.0, ppm.revenue_30d_last_year), 2) AS revenue_growth_yoy,
|
||||
|
||||
-- Demand Variability (P3)
|
||||
std_numeric(dv.sales_variance, 2) AS sales_variance_30d,
|
||||
std_numeric(dv.sales_std_dev, 2) AS sales_std_dev_30d,
|
||||
std_numeric(dv.sales_cv, 2) AS sales_cv_30d,
|
||||
classify_demand_pattern(dv.avg_daily_sales, dv.sales_cv) AS demand_pattern,
|
||||
|
||||
-- Service Levels (P5)
|
||||
std_numeric(COALESCE(sl.fill_rate_30d, 100), 2) AS fill_rate_30d,
|
||||
COALESCE(sl.stockout_incidents_30d, 0)::int AS stockout_incidents_30d,
|
||||
std_numeric(COALESCE(sl.service_level_30d, 100), 2) AS service_level_30d,
|
||||
COALESCE(sl.lost_sales_incidents_30d, 0)::int AS lost_sales_incidents_30d,
|
||||
|
||||
-- Seasonality (P5)
|
||||
std_numeric(season.seasonality_index, 2) AS seasonality_index,
|
||||
COALESCE(season.seasonal_pattern, 'none') AS seasonal_pattern,
|
||||
season.peak_season
|
||||
|
||||
FROM CurrentInfo ci
|
||||
LEFT JOIN OnOrderInfo ooi ON ci.pid = ooi.pid
|
||||
@@ -701,6 +501,11 @@ BEGIN
|
||||
LEFT JOIN SnapshotAggregates sa ON ci.pid = sa.pid
|
||||
LEFT JOIN FirstPeriodMetrics fpm ON ci.pid = fpm.pid
|
||||
LEFT JOIN Settings s ON ci.pid = s.pid
|
||||
LEFT JOIN LifetimeRevenue lr ON ci.pid = lr.pid
|
||||
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 SeasonalityAnalysis season ON ci.pid = season.pid
|
||||
WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL -- Exclude products explicitly marked
|
||||
|
||||
ON CONFLICT (pid) DO UPDATE SET
|
||||
@@ -718,7 +523,7 @@ BEGIN
|
||||
stockout_days_30d = EXCLUDED.stockout_days_30d, sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d,
|
||||
avg_stock_units_30d = EXCLUDED.avg_stock_units_30d, avg_stock_cost_30d = EXCLUDED.avg_stock_cost_30d, avg_stock_retail_30d = EXCLUDED.avg_stock_retail_30d, avg_stock_gross_30d = EXCLUDED.avg_stock_gross_30d,
|
||||
received_qty_30d = EXCLUDED.received_qty_30d, received_cost_30d = EXCLUDED.received_cost_30d,
|
||||
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue,
|
||||
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue, lifetime_revenue_quality = EXCLUDED.lifetime_revenue_quality,
|
||||
first_7_days_sales = EXCLUDED.first_7_days_sales, first_7_days_revenue = EXCLUDED.first_7_days_revenue, first_30_days_sales = EXCLUDED.first_30_days_sales, first_30_days_revenue = EXCLUDED.first_30_days_revenue,
|
||||
first_60_days_sales = EXCLUDED.first_60_days_sales, first_60_days_revenue = EXCLUDED.first_60_days_revenue, first_90_days_sales = EXCLUDED.first_90_days_sales, first_90_days_revenue = EXCLUDED.first_90_days_revenue,
|
||||
asp_30d = EXCLUDED.asp_30d, acp_30d = EXCLUDED.acp_30d, avg_ros_30d = EXCLUDED.avg_ros_30d, avg_sales_per_day_30d = EXCLUDED.avg_sales_per_day_30d, avg_sales_per_month_30d = EXCLUDED.avg_sales_per_month_30d,
|
||||
@@ -734,7 +539,22 @@ BEGIN
|
||||
stock_cover_in_days = EXCLUDED.stock_cover_in_days, po_cover_in_days = EXCLUDED.po_cover_in_days, sells_out_in_days = EXCLUDED.sells_out_in_days, replenish_date = EXCLUDED.replenish_date,
|
||||
overstocked_units = EXCLUDED.overstocked_units, overstocked_cost = EXCLUDED.overstocked_cost, overstocked_retail = EXCLUDED.overstocked_retail, is_old_stock = EXCLUDED.is_old_stock,
|
||||
yesterday_sales = EXCLUDED.yesterday_sales,
|
||||
status = EXCLUDED.status
|
||||
status = EXCLUDED.status,
|
||||
sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev,
|
||||
revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev,
|
||||
sales_growth_yoy = EXCLUDED.sales_growth_yoy,
|
||||
revenue_growth_yoy = EXCLUDED.revenue_growth_yoy,
|
||||
sales_variance_30d = EXCLUDED.sales_variance_30d,
|
||||
sales_std_dev_30d = EXCLUDED.sales_std_dev_30d,
|
||||
sales_cv_30d = EXCLUDED.sales_cv_30d,
|
||||
demand_pattern = EXCLUDED.demand_pattern,
|
||||
fill_rate_30d = EXCLUDED.fill_rate_30d,
|
||||
stockout_incidents_30d = EXCLUDED.stockout_incidents_30d,
|
||||
service_level_30d = EXCLUDED.service_level_30d,
|
||||
lost_sales_incidents_30d = EXCLUDED.lost_sales_incidents_30d,
|
||||
seasonality_index = EXCLUDED.seasonality_index,
|
||||
seasonal_pattern = EXCLUDED.seasonal_pattern,
|
||||
peak_season = EXCLUDED.peak_season
|
||||
WHERE -- Only update if at least one key metric has changed
|
||||
product_metrics.current_stock IS DISTINCT FROM EXCLUDED.current_stock OR
|
||||
product_metrics.current_price IS DISTINCT FROM EXCLUDED.current_price OR
|
||||
@@ -750,7 +570,8 @@ BEGIN
|
||||
-- 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
|
||||
product_metrics.lifetime_sales IS DISTINCT FROM EXCLUDED.lifetime_sales OR
|
||||
product_metrics.lifetime_revenue_quality IS DISTINCT FROM EXCLUDED.lifetime_revenue_quality
|
||||
;
|
||||
|
||||
-- Update the status table with the timestamp from the START of this run
|
||||
|
||||
@@ -203,12 +203,8 @@ router.get('/vendors', async (req, res) => {
|
||||
0
|
||||
) as stock_turnover,
|
||||
product_count,
|
||||
-- Use an estimate of growth based on 7-day vs 30-day revenue
|
||||
CASE
|
||||
WHEN revenue_30d > 0
|
||||
THEN ((revenue_7d * 4.0) / revenue_30d - 1) * 100
|
||||
ELSE 0
|
||||
END as growth
|
||||
-- Use actual growth metrics from the vendor_metrics table
|
||||
sales_growth_30d_vs_prev as growth
|
||||
FROM vendor_metrics
|
||||
WHERE revenue_30d > 0
|
||||
ORDER BY revenue_30d DESC
|
||||
|
||||
@@ -26,6 +26,9 @@ const COLUMN_MAP = {
|
||||
lifetimeSales: { dbCol: 'bm.lifetime_sales', type: 'number' },
|
||||
lifetimeRevenue: { dbCol: 'bm.lifetime_revenue', type: 'number' },
|
||||
avgMargin30d: { dbCol: 'bm.avg_margin_30d', type: 'number' },
|
||||
// Growth metrics
|
||||
salesGrowth30dVsPrev: { dbCol: 'bm.sales_growth_30d_vs_prev', type: 'number' },
|
||||
revenueGrowth30dVsPrev: { dbCol: 'bm.revenue_growth_30d_vs_prev', type: 'number' },
|
||||
// Add aliases if needed
|
||||
name: { dbCol: 'bm.brand_name', type: 'string' },
|
||||
// Add status for filtering
|
||||
|
||||
@@ -31,6 +31,9 @@ const COLUMN_MAP = {
|
||||
lifetimeRevenue: { dbCol: 'cm.lifetime_revenue', type: 'number' },
|
||||
avgMargin30d: { dbCol: 'cm.avg_margin_30d', type: 'number' },
|
||||
stockTurn30d: { dbCol: 'cm.stock_turn_30d', type: 'number' },
|
||||
// Growth metrics
|
||||
salesGrowth30dVsPrev: { dbCol: 'cm.sales_growth_30d_vs_prev', type: 'number' },
|
||||
revenueGrowth30dVsPrev: { dbCol: 'cm.revenue_growth_30d_vs_prev', type: 'number' },
|
||||
// Add status from the categories table for filtering
|
||||
status: { dbCol: 'c.status', type: 'string' },
|
||||
};
|
||||
|
||||
@@ -143,7 +143,33 @@ const COLUMN_MAP = {
|
||||
// Yesterday
|
||||
yesterdaySales: 'pm.yesterday_sales',
|
||||
// Map status column - directly mapped now instead of calculated on frontend
|
||||
status: 'pm.status'
|
||||
status: 'pm.status',
|
||||
|
||||
// Growth Metrics (P3)
|
||||
salesGrowth30dVsPrev: 'pm.sales_growth_30d_vs_prev',
|
||||
revenueGrowth30dVsPrev: 'pm.revenue_growth_30d_vs_prev',
|
||||
salesGrowthYoy: 'pm.sales_growth_yoy',
|
||||
revenueGrowthYoy: 'pm.revenue_growth_yoy',
|
||||
|
||||
// Demand Variability Metrics (P3)
|
||||
salesVariance30d: 'pm.sales_variance_30d',
|
||||
salesStdDev30d: 'pm.sales_std_dev_30d',
|
||||
salesCv30d: 'pm.sales_cv_30d',
|
||||
demandPattern: 'pm.demand_pattern',
|
||||
|
||||
// Service Level Metrics (P5)
|
||||
fillRate30d: 'pm.fill_rate_30d',
|
||||
stockoutIncidents30d: 'pm.stockout_incidents_30d',
|
||||
serviceLevel30d: 'pm.service_level_30d',
|
||||
lostSalesIncidents30d: 'pm.lost_sales_incidents_30d',
|
||||
|
||||
// Seasonality Metrics (P5)
|
||||
seasonalityIndex: 'pm.seasonality_index',
|
||||
seasonalPattern: 'pm.seasonal_pattern',
|
||||
peakSeason: 'pm.peak_season',
|
||||
|
||||
// Lifetime Revenue Quality
|
||||
lifetimeRevenueQuality: 'pm.lifetime_revenue_quality'
|
||||
};
|
||||
|
||||
// Define column types for use in sorting/filtering
|
||||
@@ -173,7 +199,15 @@ const COLUMN_TYPES = {
|
||||
'overstockedCost', 'overstockedRetail', 'yesterdaySales',
|
||||
// New numeric columns
|
||||
'moq', 'rating', 'reviews', 'weight', 'length', 'width', 'height',
|
||||
'baskets', 'notifies', 'preorderCount', 'notionsInvCount'
|
||||
'baskets', 'notifies', 'preorderCount', 'notionsInvCount',
|
||||
// Growth metrics
|
||||
'salesGrowth30dVsPrev', 'revenueGrowth30dVsPrev', 'salesGrowthYoy', 'revenueGrowthYoy',
|
||||
// Demand variability metrics
|
||||
'salesVariance30d', 'salesStdDev30d', 'salesCv30d',
|
||||
// Service level metrics
|
||||
'fillRate30d', 'stockoutIncidents30d', 'serviceLevel30d', 'lostSalesIncidents30d',
|
||||
// Seasonality metrics
|
||||
'seasonalityIndex'
|
||||
],
|
||||
// Date columns (use date operators and sorting)
|
||||
date: [
|
||||
@@ -185,7 +219,9 @@ const COLUMN_TYPES = {
|
||||
'sku', 'title', 'brand', 'vendor', 'imageUrl', 'abcClass', 'status',
|
||||
// New string columns
|
||||
'barcode', 'harmonizedTariffCode', 'vendorReference', 'notionsReference',
|
||||
'line', 'subline', 'artist', 'countryOfOrigin', 'location'
|
||||
'line', 'subline', 'artist', 'countryOfOrigin', 'location',
|
||||
// New string columns for patterns
|
||||
'demandPattern', 'seasonalPattern', 'peakSeason', 'lifetimeRevenueQuality'
|
||||
],
|
||||
// Boolean columns (use boolean operators and sorting)
|
||||
boolean: ['isVisible', 'isReplenishable', 'isOldStock']
|
||||
@@ -208,6 +244,12 @@ const SPECIAL_SORT_COLUMNS = {
|
||||
// Velocity columns
|
||||
salesVelocityDaily: true,
|
||||
|
||||
// Growth rate columns
|
||||
salesGrowth30dVsPrev: 'abs',
|
||||
revenueGrowth30dVsPrev: 'abs',
|
||||
salesGrowthYoy: 'abs',
|
||||
revenueGrowthYoy: 'abs',
|
||||
|
||||
// Status column needs special ordering
|
||||
status: 'priority'
|
||||
};
|
||||
|
||||
@@ -30,6 +30,9 @@ const COLUMN_MAP = {
|
||||
lifetimeSales: { dbCol: 'vm.lifetime_sales', type: 'number' },
|
||||
lifetimeRevenue: { dbCol: 'vm.lifetime_revenue', type: 'number' },
|
||||
avgMargin30d: { dbCol: 'vm.avg_margin_30d', type: 'number' },
|
||||
// Growth metrics
|
||||
salesGrowth30dVsPrev: { dbCol: 'vm.sales_growth_30d_vs_prev', type: 'number' },
|
||||
revenueGrowth30dVsPrev: { dbCol: 'vm.revenue_growth_30d_vs_prev', type: 'number' },
|
||||
// Add aliases if needed for frontend compatibility
|
||||
name: { dbCol: 'vm.vendor_name', type: 'string' },
|
||||
leadTime: { dbCol: 'vm.avg_lead_time_days', type: 'number' },
|
||||
|
||||
53
inventory/package-lock.json
generated
53
inventory/package-lock.json
generated
@@ -52,9 +52,11 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^7.0.0",
|
||||
"framer-motion": "^12.4.4",
|
||||
"input-otp": "^1.4.1",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.469.0",
|
||||
"luxon": "^3.5.0",
|
||||
"motion": "^11.18.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^18.3.1",
|
||||
@@ -75,7 +77,8 @@
|
||||
"uuid": "^11.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
@@ -5732,6 +5735,16 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/input-otp": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
|
||||
"integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
@@ -6104,6 +6117,15 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
|
||||
"integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -8316,6 +8338,35 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz",
|
||||
"integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +54,11 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^7.0.0",
|
||||
"framer-motion": "^12.4.4",
|
||||
"input-otp": "^1.4.1",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.469.0",
|
||||
"luxon": "^3.5.0",
|
||||
"motion": "^11.18.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^18.3.1",
|
||||
@@ -77,7 +79,8 @@
|
||||
"uuid": "^11.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
import { Routes, Route, useNavigate, Navigate, useLocation } from 'react-router-dom';
|
||||
import { MainLayout } from './components/layout/MainLayout';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Products } from './pages/Products';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { Analytics } from './pages/Analytics';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import PurchaseOrders from './pages/PurchaseOrders';
|
||||
import { Login } from './pages/Login';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, Suspense, lazy } from 'react';
|
||||
import config from './config';
|
||||
import { RequireAuth } from './components/auth/RequireAuth';
|
||||
import Forecasting from "@/pages/Forecasting";
|
||||
import { Vendors } from '@/pages/Vendors';
|
||||
import { Categories } from '@/pages/Categories';
|
||||
import { Import } from '@/pages/Import';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { Protected } from './components/auth/Protected';
|
||||
import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage';
|
||||
import { Brands } from '@/pages/Brands';
|
||||
import { Chat } from '@/pages/Chat';
|
||||
import { PageLoading } from '@/components/ui/page-loading';
|
||||
|
||||
// Always loaded components (Login and Settings stay in main bundle)
|
||||
import { Settings } from './pages/Settings';
|
||||
import { Login } from './pages/Login';
|
||||
|
||||
// Lazy load the 4 main chunks you wanted:
|
||||
|
||||
// 1. Core inventory app - loaded as one chunk when any inventory page is accessed
|
||||
const Overview = lazy(() => import('./pages/Overview'));
|
||||
const Products = lazy(() => import('./pages/Products').then(module => ({ default: module.Products })));
|
||||
const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ default: module.Analytics })));
|
||||
const Forecasting = lazy(() => import('./pages/Forecasting'));
|
||||
const Vendors = lazy(() => import('./pages/Vendors'));
|
||||
const Categories = lazy(() => import('./pages/Categories'));
|
||||
const Brands = lazy(() => import('./pages/Brands'));
|
||||
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
||||
|
||||
// 2. Dashboard app - separate chunk
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
|
||||
|
||||
// 3. Product import - separate chunk
|
||||
const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import })));
|
||||
|
||||
// 4. Chat archive - separate chunk
|
||||
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App() {
|
||||
@@ -73,72 +89,117 @@ function App() {
|
||||
<AuthProvider>
|
||||
<Toaster richColors position="top-center" />
|
||||
<Routes>
|
||||
{/* Always loaded routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/small" element={
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<SmallDashboard />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route element={
|
||||
<RequireAuth>
|
||||
<MainLayout />
|
||||
</RequireAuth>
|
||||
}>
|
||||
{/* Core inventory app routes - will be lazy loaded */}
|
||||
<Route index element={
|
||||
<Protected page="dashboard" fallback={<FirstAccessiblePage />}>
|
||||
<Dashboard />
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Overview />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<Protected page="dashboard">
|
||||
<Dashboard />
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Overview />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/products" element={
|
||||
<Protected page="products">
|
||||
<Products />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/import" element={
|
||||
<Protected page="import">
|
||||
<Import />
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Products />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/categories" element={
|
||||
<Protected page="categories">
|
||||
<Categories />
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Categories />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/vendors" element={
|
||||
<Protected page="vendors">
|
||||
<Vendors />
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Vendors />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/brands" element={
|
||||
<Protected page="brands">
|
||||
<Brands />
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Brands />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/purchase-orders" element={
|
||||
<Protected page="purchase_orders">
|
||||
<PurchaseOrders />
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<PurchaseOrders />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/analytics" element={
|
||||
<Protected page="analytics">
|
||||
<Analytics />
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Analytics />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/forecasting" element={
|
||||
<Protected page="forecasting">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Forecasting />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Always loaded settings */}
|
||||
<Route path="/settings" element={
|
||||
<Protected page="settings">
|
||||
<Settings />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/forecasting" element={
|
||||
<Protected page="forecasting">
|
||||
<Forecasting />
|
||||
|
||||
{/* Product import - separate chunk */}
|
||||
<Route path="/import" element={
|
||||
<Protected page="import">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Import />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Chat archive - separate chunk */}
|
||||
<Route path="/chat" element={
|
||||
<Protected page="chat">
|
||||
<Chat />
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Chat />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Dashboard app - separate chunk */}
|
||||
<Route path="/dashboard" element={
|
||||
<Protected page="dashboard">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Dashboard />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Loader2, Hash, Lock, MessageSquare, ChevronUp, Search, ExternalLink, FileText, Image, Download, MessageCircle, Users2 } from 'lucide-react';
|
||||
import { Loader2, Hash, MessageSquare, ChevronUp, Search, ExternalLink, FileText, Image, Download, MessageCircle, Users2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import config from '@/config';
|
||||
import { convertEmojiShortcodes } from '@/utils/emojiUtils';
|
||||
@@ -63,7 +62,6 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -180,7 +178,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
setSearchResults(data.results);
|
||||
// Handle search results
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error searching messages:', err);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Hash, Lock, Users, MessageSquare } from 'lucide-react';
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Loader2, Hash, Lock, Users, MessageSquare, Search, MessageCircle, Users2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Loader2, Hash, Users, MessageSquare, MessageCircle, Users2 } from 'lucide-react';
|
||||
import config from '@/config';
|
||||
|
||||
interface Room {
|
||||
@@ -38,7 +36,7 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
|
||||
const [filteredRooms, setFilteredRooms] = useState<Room[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [searchFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUserId) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
133
inventory/src/components/dashboard/AcotTest.jsx
Normal file
133
inventory/src/components/dashboard/AcotTest.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
|
||||
import { Button } from "@/components/dashboard/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
|
||||
import { Loader2, AlertCircle, CheckCircle, RefreshCw } from "lucide-react";
|
||||
|
||||
const AcotTest = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [data, setData] = useState(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState(null);
|
||||
|
||||
const testConnection = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await axios.get("/api/acot/test/test-connection");
|
||||
setConnectionStatus(response.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || err.message);
|
||||
setConnectionStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchOrderCount = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await axios.get("/api/acot/test/order-count");
|
||||
setData(response.data.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || err.message);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
testConnection();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
ACOT Server Test
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
testConnection();
|
||||
if (connectionStatus?.success) {
|
||||
fetchOrderCount();
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Connection Status */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Connection Status</h3>
|
||||
{connectionStatus?.success ? (
|
||||
<Alert className="bg-green-50 border-green-200">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertTitle className="text-green-800">Connected</AlertTitle>
|
||||
<AlertDescription className="text-green-700">
|
||||
{connectionStatus.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Connection Failed</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Testing connection...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Order Count */}
|
||||
{connectionStatus?.success && (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
onClick={fetchOrderCount}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"Fetch Order Count"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{data && (
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Total Orders in Database
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{data.orderCount?.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Last updated: {new Date(data.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AcotTest;
|
||||
608
inventory/src/components/dashboard/AircallDashboard.jsx
Normal file
608
inventory/src/components/dashboard/AircallDashboard.jsx
Normal file
@@ -0,0 +1,608 @@
|
||||
// components/AircallDashboard.jsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/dashboard/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/dashboard/ui/select";
|
||||
import { Alert, AlertDescription } from "@/components/dashboard/ui/alert";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/dashboard/ui/table";
|
||||
import {
|
||||
PhoneCall,
|
||||
PhoneMissed,
|
||||
Clock,
|
||||
UserCheck,
|
||||
PhoneIncoming,
|
||||
PhoneOutgoing,
|
||||
ArrowUpDown,
|
||||
Timer,
|
||||
Loader2,
|
||||
Download,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/dashboard/ui/button";
|
||||
import { Input } from "@/components/dashboard/ui/input";
|
||||
import { Progress } from "@/components/dashboard/ui/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/dashboard/ui/tooltip";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
} from "recharts";
|
||||
|
||||
const COLORS = {
|
||||
inbound: "hsl(262.1 83.3% 57.8%)", // Purple
|
||||
outbound: "hsl(142.1 76.2% 36.3%)", // Green
|
||||
missed: "hsl(47.9 95.8% 53.1%)", // Yellow
|
||||
answered: "hsl(142.1 76.2% 36.3%)", // Green
|
||||
duration: "hsl(221.2 83.2% 53.3%)", // Blue
|
||||
hourly: "hsl(321.2 81.1% 41.2%)", // Pink
|
||||
};
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: "Today", value: "today" },
|
||||
{ label: "Yesterday", value: "yesterday" },
|
||||
{ label: "Last 7 Days", value: "last7days" },
|
||||
{ label: "Last 30 Days", value: "last30days" },
|
||||
{ label: "Last 90 Days", value: "last90days" },
|
||||
];
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
const MetricCard = ({ title, value, subtitle, icon: Icon, iconColor }) => (
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 p-4">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">{title}</CardTitle>
|
||||
<Icon className={`h-4 w-4 ${iconColor}`} />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">{value}</div>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{subtitle}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border-none">
|
||||
<CardContent className="p-0 space-y-2">
|
||||
<p className="font-medium text-sm text-gray-900 dark:text-gray-100 border-b border-gray-100 dark:border-gray-800 pb-1 mb-2">{label}</p>
|
||||
{payload.map((entry, index) => (
|
||||
<p key={index} className="text-sm text-muted-foreground">
|
||||
{`${entry.name}: ${entry.value}`}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
|
||||
const AgentPerformanceTable = ({ agents, onSort }) => {
|
||||
const [sortConfig, setSortConfig] = useState({
|
||||
key: "total",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
const handleSort = (key) => {
|
||||
const direction =
|
||||
sortConfig.key === key && sortConfig.direction === "desc"
|
||||
? "asc"
|
||||
: "desc";
|
||||
setSortConfig({ key, direction });
|
||||
onSort(key, direction);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead onClick={() => handleSort("total")}>Total Calls</TableHead>
|
||||
<TableHead onClick={() => handleSort("answered")}>Answered</TableHead>
|
||||
<TableHead onClick={() => handleSort("missed")}>Missed</TableHead>
|
||||
<TableHead onClick={() => handleSort("average_duration")}>Average Duration</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{agents.map((agent) => (
|
||||
<TableRow key={agent.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">{agent.name}</TableCell>
|
||||
<TableCell>{agent.total}</TableCell>
|
||||
<TableCell className="text-emerald-600 dark:text-emerald-400">{agent.answered}</TableCell>
|
||||
<TableCell className="text-rose-600 dark:text-rose-400">{agent.missed}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDuration(agent.average_duration)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
const SkeletonMetricCard = () => (
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-col items-start p-4">
|
||||
<Skeleton className="h-4 w-24 mb-2 bg-muted" />
|
||||
<Skeleton className="h-8 w-32 mb-2 bg-muted" />
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-4 w-20 bg-muted" />
|
||||
<Skeleton className="h-4 w-20 bg-muted" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const SkeletonChart = ({ type = "line" }) => (
|
||||
<div className="h-[300px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-4">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 relative">
|
||||
{type === "bar" ? (
|
||||
<div className="h-full flex items-end justify-between gap-1">
|
||||
{[...Array(24)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-full bg-muted rounded-t animate-pulse"
|
||||
style={{ height: `${15 + Math.random() * 70}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full relative">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-full h-px bg-muted"
|
||||
style={{ top: `${20 + i * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="absolute inset-0 bg-muted animate-pulse"
|
||||
style={{
|
||||
opacity: 0.2,
|
||||
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SkeletonTable = ({ rows = 5 }) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
|
||||
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
|
||||
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
|
||||
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
|
||||
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(rows)].map((_, i) => (
|
||||
<TableRow key={i} className="hover:bg-muted/50 transition-colors">
|
||||
<TableCell><Skeleton className="h-4 w-32 bg-muted" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16 bg-muted" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16 bg-muted" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16 bg-muted" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-24 bg-muted" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
const AircallDashboard = () => {
|
||||
const [timeRange, setTimeRange] = useState("last7days");
|
||||
const [metrics, setMetrics] = useState(null);
|
||||
const [lastUpdated, setLastUpdated] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [agentSort, setAgentSort] = useState({
|
||||
key: "total",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
const safeArray = (arr) => (Array.isArray(arr) ? arr : []);
|
||||
const safeObject = (obj) => (obj && typeof obj === "object" ? obj : {});
|
||||
|
||||
const sortedAgents = metrics?.by_users
|
||||
? Object.values(metrics.by_users).sort((a, b) => {
|
||||
const multiplier = agentSort.direction === "desc" ? -1 : 1;
|
||||
return multiplier * (a[agentSort.key] - b[agentSort.key]);
|
||||
})
|
||||
: [];
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
try {
|
||||
// Parse the date string (YYYY-MM-DD)
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
|
||||
// Create a date object in ET timezone
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
|
||||
// Format the date in ET timezone
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "America/New_York"
|
||||
}).format(date);
|
||||
} catch (error) {
|
||||
console.error("Date formatting error:", error, { dateString });
|
||||
return "Invalid Date";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleExport = () => {
|
||||
const timestamp = new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(new Date());
|
||||
|
||||
exportToCSV(filteredAgents, `aircall-agent-metrics-${timestamp}`);
|
||||
};
|
||||
|
||||
const chartData = {
|
||||
hourly: metrics?.by_hour
|
||||
? metrics.by_hour.map((count, hour) => ({
|
||||
hour: new Date(2000, 0, 1, hour).toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
hour12: true
|
||||
}).toUpperCase(),
|
||||
calls: count || 0,
|
||||
}))
|
||||
: [],
|
||||
|
||||
missedReasons: metrics?.by_missed_reason
|
||||
? Object.entries(metrics.by_missed_reason).map(([reason, count]) => ({
|
||||
reason: (reason || "").replace(/_/g, " "),
|
||||
count: count || 0,
|
||||
}))
|
||||
: [],
|
||||
|
||||
daily: safeArray(metrics?.daily_data).map((day) => ({
|
||||
...day,
|
||||
inbound: day.inbound || 0,
|
||||
outbound: day.outbound || 0,
|
||||
date: new Date(day.date).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
const peakHour = metrics?.by_hour
|
||||
? metrics.by_hour.indexOf(Math.max(...metrics.by_hour))
|
||||
: null;
|
||||
|
||||
const busyAgent = sortedAgents?.length > 0 ? sortedAgents[0] : null;
|
||||
|
||||
const bestAnswerRate = sortedAgents
|
||||
?.filter((agent) => agent.total > 0)
|
||||
?.sort((a, b) => b.answered / b.total - a.answered / a.total)[0];
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`/api/aircall/metrics/${timeRange}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch metrics");
|
||||
const data = await response.json();
|
||||
setMetrics(data);
|
||||
setLastUpdated(data._meta?.generatedAt);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, REFRESH_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [timeRange]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20">
|
||||
Error loading call data: {error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">Calls</CardTitle>
|
||||
</div>
|
||||
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-[130px] h-9 bg-white dark:bg-gray-800">
|
||||
<SelectValue placeholder="Select range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_RANGES.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 pt-0 space-y-4">
|
||||
{/* Metric Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{isLoading ? (
|
||||
[...Array(4)].map((_, i) => (
|
||||
<SkeletonMetricCard key={i} />
|
||||
))
|
||||
) : metrics ? (
|
||||
<>
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-col items-start p-4">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Calls</CardTitle>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-2">{metrics.total}</div>
|
||||
<div className="flex gap-4 mt-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-blue-500">↑ {metrics.by_direction.inbound}</span> inbound
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-emerald-500">↓ {metrics.by_direction.outbound}</span> outbound
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-col items-start p-4">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Answer Rate</CardTitle>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-2">
|
||||
{`${((metrics.by_status.answered / metrics.total) * 100).toFixed(1)}%`}
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-emerald-500">{metrics.by_status.answered}</span> answered
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-rose-500">{metrics.by_status.missed}</span> missed
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-col items-start p-4">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Peak Hour</CardTitle>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-2">
|
||||
{metrics?.by_hour ? new Date(2000, 0, 1, metrics.by_hour.indexOf(Math.max(...metrics.by_hour))).toLocaleString('en-US', { hour: 'numeric', hour12: true }).toUpperCase() : 'N/A'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
Busiest Agent: {sortedAgents[0]?.name || "N/A"}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-col items-start p-4">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Avg Duration</CardTitle>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatDuration(metrics.average_duration)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
{metrics?.daily_data?.length > 0
|
||||
? `${Math.round(metrics.total / metrics.daily_data.length)} calls/day`
|
||||
: "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="w-[300px] bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">Duration Distribution</p>
|
||||
{metrics?.duration_distribution?.map((d, i) => (
|
||||
<div key={i} className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{d.range}</span>
|
||||
<span>{d.count} calls</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Charts and Tables Section */}
|
||||
<div className="space-y-4">
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Daily Call Volume */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Daily Call Volume</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
{isLoading ? (
|
||||
<SkeletonChart type="bar" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData.daily} margin={{ top: 0, right: 5, left: -35, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Bar dataKey="inbound" fill={COLORS.inbound} name="Inbound" />
|
||||
<Bar dataKey="outbound" fill={COLORS.outbound} name="Outbound" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Hourly Distribution */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Hourly Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
{isLoading ? (
|
||||
<SkeletonChart type="bar" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData.hourly} margin={{ top: 0, right: 5, left: -35, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={2}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="calls" fill={COLORS.hourly} name="Calls" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tables Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Agent Performance */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Agent Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<SkeletonTable rows={5} />
|
||||
) : (
|
||||
<div className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
||||
<AgentPerformanceTable
|
||||
agents={sortedAgents}
|
||||
onSort={(key, direction) => setAgentSort({ key, direction })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Missed Call Reasons Table */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Missed Call Reasons</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<SkeletonTable rows={5} />
|
||||
) : (
|
||||
<div className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-medium text-gray-900 dark:text-gray-100">Reason</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Count</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{chartData.missedReasons.map((reason, index) => (
|
||||
<TableRow key={index} className="hover:bg-muted/50 transition-colors">
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{reason.reason}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-rose-600 dark:text-rose-400">
|
||||
{reason.count}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AircallDashboard;
|
||||
597
inventory/src/components/dashboard/AnalyticsDashboard.jsx
Normal file
597
inventory/src/components/dashboard/AnalyticsDashboard.jsx
Normal file
@@ -0,0 +1,597 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/dashboard/ui/select";
|
||||
import { Button } from "@/components/dashboard/ui/button";
|
||||
import { Separator } from "@/components/dashboard/ui/separator";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { Loader2, TrendingUp, AlertCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/dashboard/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/dashboard/ui/table";
|
||||
|
||||
// Add helper function for currency formatting
|
||||
const formatCurrency = (value, useFractionDigits = true) => {
|
||||
if (typeof value !== "number") return "$0.00";
|
||||
const roundedValue = parseFloat(value.toFixed(useFractionDigits ? 2 : 0));
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: useFractionDigits ? 2 : 0,
|
||||
maximumFractionDigits: useFractionDigits ? 2 : 0,
|
||||
}).format(roundedValue);
|
||||
};
|
||||
|
||||
// Add skeleton components
|
||||
const SkeletonChart = () => (
|
||||
<div className="h-[400px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-6">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 relative">
|
||||
{/* Grid lines */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-full h-px bg-muted"
|
||||
style={{ top: `${(i + 1) * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-6 bg-muted rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* X-axis labels */}
|
||||
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-8 bg-muted rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* Chart line */}
|
||||
<div className="absolute inset-x-8 bottom-6 top-4">
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
className="absolute inset-0 bg-muted rounded-sm"
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SkeletonStats = () => (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i} className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<Skeleton className="h-8 w-32 bg-muted rounded-sm mb-2" />
|
||||
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SkeletonButtons = () => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-20 bg-muted rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Add StatCard component
|
||||
const StatCard = ({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
trend,
|
||||
trendValue,
|
||||
colorClass = "text-gray-900 dark:text-gray-100",
|
||||
}) => (
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<span className="text-sm text-muted-foreground font-medium">{title}</span>
|
||||
{trend && (
|
||||
<span
|
||||
className={`text-sm flex items-center gap-1 font-medium ${
|
||||
trend === "up"
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-rose-600 dark:text-rose-400"
|
||||
}`}
|
||||
>
|
||||
{trendValue}
|
||||
</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className={`text-2xl font-bold mb-1.5 ${colorClass}`}>{value}</div>
|
||||
{description && (
|
||||
<div className="text-sm font-medium text-muted-foreground">{description}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Add color constants
|
||||
const METRIC_COLORS = {
|
||||
activeUsers: {
|
||||
color: "#8b5cf6",
|
||||
className: "text-purple-600 dark:text-purple-400",
|
||||
},
|
||||
newUsers: {
|
||||
color: "#10b981",
|
||||
className: "text-emerald-600 dark:text-emerald-400",
|
||||
},
|
||||
pageViews: {
|
||||
color: "#f59e0b",
|
||||
className: "text-amber-600 dark:text-amber-400",
|
||||
},
|
||||
conversions: {
|
||||
color: "#3b82f6",
|
||||
className: "text-blue-600 dark:text-blue-400",
|
||||
},
|
||||
};
|
||||
|
||||
export const AnalyticsDashboard = () => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState("30");
|
||||
const [metrics, setMetrics] = useState({
|
||||
activeUsers: true,
|
||||
newUsers: true,
|
||||
pageViews: true,
|
||||
conversions: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/dashboard-analytics/metrics?startDate=${timeRange}daysAgo`,
|
||||
{
|
||||
credentials: "include",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch metrics");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result?.data?.rows) {
|
||||
console.log("No result data received");
|
||||
return;
|
||||
}
|
||||
|
||||
const processedData = result.data.rows.map((row) => ({
|
||||
date: formatGADate(row.dimensionValues[0].value),
|
||||
activeUsers: parseInt(row.metricValues[0].value),
|
||||
newUsers: parseInt(row.metricValues[1].value),
|
||||
avgSessionDuration: parseFloat(row.metricValues[2].value),
|
||||
pageViews: parseInt(row.metricValues[3].value),
|
||||
bounceRate: parseFloat(row.metricValues[4].value) * 100,
|
||||
conversions: parseInt(row.metricValues[5].value),
|
||||
}));
|
||||
|
||||
const sortedData = processedData.sort((a, b) => a.date - b.date);
|
||||
setData(sortedData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch analytics:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [timeRange]);
|
||||
|
||||
const formatGADate = (gaDate) => {
|
||||
const year = gaDate.substring(0, 4);
|
||||
const month = gaDate.substring(4, 6);
|
||||
const day = gaDate.substring(6, 8);
|
||||
return new Date(year, month - 1, day);
|
||||
};
|
||||
|
||||
const formatXAxis = (date) => {
|
||||
if (!date) return "";
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const calculateSummaryStats = () => {
|
||||
if (!data.length) return null;
|
||||
|
||||
const totals = data.reduce(
|
||||
(acc, day) => ({
|
||||
activeUsers: acc.activeUsers + day.activeUsers,
|
||||
newUsers: acc.newUsers + day.newUsers,
|
||||
pageViews: acc.pageViews + day.pageViews,
|
||||
conversions: acc.conversions + day.conversions,
|
||||
}),
|
||||
{
|
||||
activeUsers: 0,
|
||||
newUsers: 0,
|
||||
pageViews: 0,
|
||||
conversions: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const averages = {
|
||||
activeUsers: totals.activeUsers / data.length,
|
||||
newUsers: totals.newUsers / data.length,
|
||||
pageViews: totals.pageViews / data.length,
|
||||
conversions: totals.conversions / data.length,
|
||||
};
|
||||
|
||||
return { totals, averages };
|
||||
};
|
||||
|
||||
const summaryStats = calculateSummaryStats();
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border border-border">
|
||||
<CardContent className="p-0 space-y-2">
|
||||
<p className="font-medium text-sm border-b border-border pb-1.5 mb-2 text-foreground">
|
||||
{label instanceof Date ? label.toLocaleDateString() : label}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span className="font-medium" style={{ color: entry.color }}>{entry.name}:</span>
|
||||
<span className="font-medium ml-4 text-foreground">
|
||||
{entry.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-6 pb-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Analytics Overview
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{loading ? (
|
||||
<Skeleton className="h-9 w-[130px] bg-muted rounded-sm" />
|
||||
) : (
|
||||
<>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="h-9">
|
||||
Details
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<DialogHeader className="flex-none">
|
||||
<DialogTitle className="text-gray-900 dark:text-gray-100">Daily Details</DialogTitle>
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(metrics).map(([key, value]) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setMetrics((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}))
|
||||
}
|
||||
>
|
||||
{key === "activeUsers" ? "Active Users" :
|
||||
key === "newUsers" ? "New Users" :
|
||||
key === "pageViews" ? "Page Views" :
|
||||
"Conversions"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto mt-6">
|
||||
<div className="rounded-lg border bg-white dark:bg-gray-900/60 backdrop-blur-sm w-full">
|
||||
<Table className="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-center whitespace-nowrap px-6 w-[120px]">Date</TableHead>
|
||||
{metrics.activeUsers && (
|
||||
<TableHead className="text-center whitespace-nowrap px-6 min-w-[100px]">Active Users</TableHead>
|
||||
)}
|
||||
{metrics.newUsers && (
|
||||
<TableHead className="text-center whitespace-nowrap px-6 min-w-[100px]">New Users</TableHead>
|
||||
)}
|
||||
{metrics.pageViews && (
|
||||
<TableHead className="text-center whitespace-nowrap px-6 min-w-[140px]">Page Views</TableHead>
|
||||
)}
|
||||
{metrics.conversions && (
|
||||
<TableHead className="text-center whitespace-nowrap px-6 min-w-[120px]">Conversions</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((day) => (
|
||||
<TableRow key={day.date}>
|
||||
<TableCell className="text-center whitespace-nowrap px-6">{formatXAxis(day.date)}</TableCell>
|
||||
{metrics.activeUsers && (
|
||||
<TableCell className="text-center whitespace-nowrap px-6">
|
||||
{day.activeUsers.toLocaleString()}
|
||||
</TableCell>
|
||||
)}
|
||||
{metrics.newUsers && (
|
||||
<TableCell className="text-center whitespace-nowrap px-6">
|
||||
{day.newUsers.toLocaleString()}
|
||||
</TableCell>
|
||||
)}
|
||||
{metrics.pageViews && (
|
||||
<TableCell className="text-center whitespace-nowrap px-6">
|
||||
{day.pageViews.toLocaleString()}
|
||||
</TableCell>
|
||||
)}
|
||||
{metrics.conversions && (
|
||||
<TableCell className="text-center whitespace-nowrap px-6">
|
||||
{day.conversions.toLocaleString()}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-[130px] h-9">
|
||||
<SelectValue placeholder="Select range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="14">Last 14 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<SkeletonStats />
|
||||
) : summaryStats ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
|
||||
<StatCard
|
||||
title="Active Users"
|
||||
value={summaryStats.totals.activeUsers.toLocaleString()}
|
||||
description={`Avg: ${Math.round(
|
||||
summaryStats.averages.activeUsers
|
||||
).toLocaleString()} per day`}
|
||||
colorClass={METRIC_COLORS.activeUsers.className}
|
||||
/>
|
||||
<StatCard
|
||||
title="New Users"
|
||||
value={summaryStats.totals.newUsers.toLocaleString()}
|
||||
description={`Avg: ${Math.round(
|
||||
summaryStats.averages.newUsers
|
||||
).toLocaleString()} per day`}
|
||||
colorClass={METRIC_COLORS.newUsers.className}
|
||||
/>
|
||||
<StatCard
|
||||
title="Page Views"
|
||||
value={summaryStats.totals.pageViews.toLocaleString()}
|
||||
description={`Avg: ${Math.round(
|
||||
summaryStats.averages.pageViews
|
||||
).toLocaleString()} per day`}
|
||||
colorClass={METRIC_COLORS.pageViews.className}
|
||||
/>
|
||||
<StatCard
|
||||
title="Conversions"
|
||||
value={summaryStats.totals.conversions.toLocaleString()}
|
||||
description={`Avg: ${Math.round(
|
||||
summaryStats.averages.conversions
|
||||
).toLocaleString()} per day`}
|
||||
colorClass={METRIC_COLORS.conversions.className}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4 pt-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button
|
||||
variant={metrics.activeUsers ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="font-medium"
|
||||
onClick={() =>
|
||||
setMetrics((prev) => ({
|
||||
...prev,
|
||||
activeUsers: !prev.activeUsers,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className="hidden sm:inline">Active Users</span>
|
||||
<span className="sm:hidden">Active</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={metrics.newUsers ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="font-medium"
|
||||
onClick={() =>
|
||||
setMetrics((prev) => ({
|
||||
...prev,
|
||||
newUsers: !prev.newUsers,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className="hidden sm:inline">New Users</span>
|
||||
<span className="sm:hidden">New</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={metrics.pageViews ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="font-medium"
|
||||
onClick={() =>
|
||||
setMetrics((prev) => ({
|
||||
...prev,
|
||||
pageViews: !prev.pageViews,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className="hidden sm:inline">Page Views</span>
|
||||
<span className="sm:hidden">Views</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={metrics.conversions ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="font-medium"
|
||||
onClick={() =>
|
||||
setMetrics((prev) => ({
|
||||
...prev,
|
||||
conversions: !prev.conversions,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className="hidden sm:inline">Conversions</span>
|
||||
<span className="sm:hidden">Conv.</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 pt-0">
|
||||
{loading ? (
|
||||
<SkeletonChart />
|
||||
) : !data.length ? (
|
||||
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<TrendingUp className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<div className="font-medium mb-2 text-gray-900 dark:text-gray-100">No analytics data available</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Try selecting a different time range
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[400px] mt-4 bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-0 relative">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: -30, left: -5, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatXAxis}
|
||||
className="text-xs text-muted-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
className="text-xs text-muted-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
className="text-xs text-muted-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
{metrics.activeUsers && (
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="activeUsers"
|
||||
name="Active Users"
|
||||
stroke={METRIC_COLORS.activeUsers.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
{metrics.newUsers && (
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="newUsers"
|
||||
name="New Users"
|
||||
stroke={METRIC_COLORS.newUsers.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
{metrics.pageViews && (
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="pageViews"
|
||||
name="Page Views"
|
||||
stroke={METRIC_COLORS.pageViews.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
{metrics.conversions && (
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="conversions"
|
||||
name="Conversions"
|
||||
stroke={METRIC_COLORS.conversions.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsDashboard;
|
||||
456
inventory/src/components/dashboard/DateTime.jsx
Normal file
456
inventory/src/components/dashboard/DateTime.jsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent } from '@/components/dashboard/ui/card';
|
||||
import { Calendar as CalendarComponent } from '@/components/dashboard/ui/calendaredit';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/dashboard/ui/popover';
|
||||
import { Alert, AlertDescription } from '@/components/dashboard/ui/alert';
|
||||
import {
|
||||
Sun,
|
||||
Cloud,
|
||||
CloudRain,
|
||||
CloudDrizzle,
|
||||
CloudSnow,
|
||||
CloudLightning,
|
||||
CloudFog,
|
||||
CloudSun,
|
||||
CircleAlert,
|
||||
Tornado,
|
||||
Haze,
|
||||
Moon,
|
||||
Wind,
|
||||
Droplets,
|
||||
ThermometerSun,
|
||||
ThermometerSnowflake,
|
||||
Sunrise,
|
||||
Sunset,
|
||||
AlertTriangle,
|
||||
Umbrella,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
||||
const [datetime, setDatetime] = useState(new Date());
|
||||
const [prevTime, setPrevTime] = useState(getTimeComponents(new Date()));
|
||||
const [isTimeChanging, setIsTimeChanging] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [weather, setWeather] = useState(null);
|
||||
const [forecast, setForecast] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => setMounted(true), 150);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const newDate = new Date();
|
||||
const newTime = getTimeComponents(newDate);
|
||||
|
||||
if (newTime.minutes !== prevTime.minutes) {
|
||||
setIsTimeChanging(true);
|
||||
setTimeout(() => setIsTimeChanging(false), 200);
|
||||
}
|
||||
|
||||
setPrevTime(newTime);
|
||||
setDatetime(newDate);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [prevTime]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWeatherData = async () => {
|
||||
try {
|
||||
const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY;
|
||||
const [weatherResponse, forecastResponse] = await Promise.all([
|
||||
fetch(
|
||||
`https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
||||
),
|
||||
fetch(
|
||||
`https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
||||
)
|
||||
]);
|
||||
|
||||
const weatherData = await weatherResponse.json();
|
||||
const forecastData = await forecastResponse.json();
|
||||
|
||||
setWeather(weatherData);
|
||||
|
||||
// Process forecast data to get daily forecasts with precipitation
|
||||
const dailyForecasts = forecastData.list.reduce((acc, item) => {
|
||||
const date = new Date(item.dt * 1000).toLocaleDateString();
|
||||
if (!acc[date]) {
|
||||
acc[date] = {
|
||||
...item,
|
||||
precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0,
|
||||
pop: item.pop * 100 // Probability of precipitation as percentage
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
setForecast(Object.values(dailyForecasts).slice(0, 5));
|
||||
} catch (error) {
|
||||
console.error("Error fetching weather:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchWeatherData();
|
||||
const weatherTimer = setInterval(fetchWeatherData, 300000);
|
||||
return () => clearInterval(weatherTimer);
|
||||
}, []);
|
||||
|
||||
function getTimeComponents(date) {
|
||||
let hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
hours = hours % 12;
|
||||
hours = hours ? hours : 12;
|
||||
return {
|
||||
hours: hours.toString(),
|
||||
minutes: minutes.toString().padStart(2, '0'),
|
||||
ampm
|
||||
};
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
return {
|
||||
weekday: date.toLocaleDateString('en-US', { weekday: 'long' }),
|
||||
month: date.toLocaleDateString('en-US', { month: 'long' }),
|
||||
day: date.getDate()
|
||||
};
|
||||
};
|
||||
|
||||
const getWeatherIcon = (weatherCode, currentTime, small = false) => {
|
||||
if (!weatherCode) return <CircleAlert className="w-12 h-12 text-red-500" />;
|
||||
const code = parseInt(weatherCode, 10);
|
||||
const iconProps = small ? "w-8 h-8" : "w-12 h-12";
|
||||
const isNight = currentTime.getHours() >= 18 || currentTime.getHours() < 6;
|
||||
|
||||
switch (true) {
|
||||
case code >= 200 && code < 300:
|
||||
return <CloudLightning className={cn(iconProps, "text-yellow-300")} />;
|
||||
case code >= 300 && code < 500:
|
||||
return <CloudDrizzle className={cn(iconProps, "text-blue-300")} />;
|
||||
case code >= 500 && code < 600:
|
||||
return <CloudRain className={cn(iconProps, "text-blue-300")} />;
|
||||
case code >= 600 && code < 700:
|
||||
return <CloudSnow className={cn(iconProps, "text-blue-200")} />;
|
||||
case code >= 700 && code < 721:
|
||||
return <CloudFog className={cn(iconProps, "text-gray-300")} />;
|
||||
case code === 721:
|
||||
return <Haze className={cn(iconProps, "text-gray-300")} />;
|
||||
case code >= 722 && code < 781:
|
||||
return <CloudFog className={cn(iconProps, "text-gray-300")} />;
|
||||
case code === 781:
|
||||
return <Tornado className={cn(iconProps, "text-gray-300")} />;
|
||||
case code === 800:
|
||||
return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? (
|
||||
<Sun className={cn(iconProps, "text-yellow-300")} />
|
||||
) : (
|
||||
<Moon className={cn(iconProps, "text-gray-300")} />
|
||||
);
|
||||
case code >= 800 && code < 803:
|
||||
return <CloudSun className={cn(iconProps, isNight ? "text-gray-300" : "text-gray-200")} />;
|
||||
case code >= 803:
|
||||
return <Cloud className={cn(iconProps, "text-gray-300")} />;
|
||||
default:
|
||||
return <CircleAlert className={cn(iconProps, "text-red-500")} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getWeatherBackground = (weatherCode, isNight) => {
|
||||
const code = parseInt(weatherCode, 10);
|
||||
|
||||
// Thunderstorm (200-299)
|
||||
if (code >= 200 && code < 300) {
|
||||
return "bg-gradient-to-br from-slate-900 to-purple-800";
|
||||
}
|
||||
|
||||
// Drizzle (300-399)
|
||||
if (code >= 300 && code < 400) {
|
||||
return "bg-gradient-to-br from-slate-800 to-blue-800";
|
||||
}
|
||||
|
||||
// Rain (500-599)
|
||||
if (code >= 500 && code < 600) {
|
||||
return "bg-gradient-to-br from-slate-800 to-blue-800";
|
||||
}
|
||||
|
||||
// Snow (600-699)
|
||||
if (code >= 600 && code < 700) {
|
||||
return "bg-gradient-to-br from-slate-700 to-blue-800";
|
||||
}
|
||||
|
||||
// Atmosphere (700-799: mist, smoke, haze, fog, etc.)
|
||||
if (code >= 700 && code < 800) {
|
||||
return "bg-gradient-to-br from-slate-700 to-slate-500";
|
||||
}
|
||||
|
||||
// Clear (800)
|
||||
if (code === 800) {
|
||||
if (isNight) {
|
||||
return "bg-gradient-to-br from-slate-900 to-blue-900";
|
||||
}
|
||||
return "bg-gradient-to-br from-blue-600 to-sky-400";
|
||||
}
|
||||
|
||||
// Clouds (801-804)
|
||||
if (code > 800) {
|
||||
if (isNight) {
|
||||
return "bg-gradient-to-br from-slate-800 to-slate-600";
|
||||
}
|
||||
return "bg-gradient-to-br from-slate-600 to-slate-400";
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return "bg-gradient-to-br from-slate-700 to-slate-500";
|
||||
};
|
||||
|
||||
const getTemperatureColor = (weatherCode, isNight) => {
|
||||
const code = parseInt(weatherCode, 10);
|
||||
|
||||
// Snow - dark background, light text
|
||||
if (code >= 600 && code < 700) {
|
||||
return "text-white";
|
||||
}
|
||||
|
||||
// Clear day - light background, dark text
|
||||
if (code === 800 && !isNight) {
|
||||
return "text-white";
|
||||
}
|
||||
|
||||
// Cloudy day - medium background, ensure contrast
|
||||
if (code > 800 && !isNight) {
|
||||
return "text-white";
|
||||
}
|
||||
|
||||
// All other cases (darker backgrounds)
|
||||
return "text-white";
|
||||
};
|
||||
|
||||
const { hours, minutes, ampm } = getTimeComponents(datetime);
|
||||
const dateInfo = formatDate(datetime);
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return '--:--';
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
};
|
||||
|
||||
const WeatherDetails = () => (
|
||||
<div className="space-y-4 p-3 bg-gradient-to-br from-slate-800 to-slate-700">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ThermometerSun className="w-5 h-5 text-yellow-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-300">High</span>
|
||||
<span className="text-sm font-bold text-white">{Math.round(weather.main.temp_max)}°F</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ThermometerSnowflake className="w-5 h-5 text-blue-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-300">Low</span>
|
||||
<span className="text-sm font-bold text-white">{Math.round(weather.main.temp_min)}°F</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Droplets className="w-5 h-5 text-blue-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-300">Humidity</span>
|
||||
<span className="text-sm font-bold text-white">{weather.main.humidity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Wind className="w-5 h-5 text-slate-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-300">Wind</span>
|
||||
<span className="text-sm font-bold text-white">{Math.round(weather.wind.speed)} mph</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Sunrise className="w-5 h-5 text-yellow-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-300">Sunrise</span>
|
||||
<span className="text-sm font-bold text-white">{formatTime(weather.sys?.sunrise)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Sunset className="w-5 h-5 text-orange-300" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-300">Sunset</span>
|
||||
<span className="text-sm font-bold text-white">{formatTime(weather.sys?.sunset)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{forecast && (
|
||||
<div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{forecast.map((day, index) => {
|
||||
const forecastTime = new Date(day.dt * 1000);
|
||||
const isNight = forecastTime.getHours() >= 18 || forecastTime.getHours() < 6;
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
className={cn(
|
||||
getWeatherBackground(day.weather[0].id, isNight),
|
||||
"p-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{forecastTime.toLocaleDateString('en-US', { weekday: 'short' })}
|
||||
</span>
|
||||
{getWeatherIcon(day.weather[0].id, forecastTime, true)}
|
||||
<div className="flex justify-center gap-1 items-baseline w-full">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{Math.round(day.main.temp_max)}°
|
||||
</span>
|
||||
<span className="text-xs text-slate-300">
|
||||
{Math.round(day.main.temp_min)}°
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 w-full pt-1">
|
||||
{day.rain?.['3h'] > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CloudRain className="w-3 h-3 text-blue-300" />
|
||||
<span className="text-xs text-white">{day.rain['3h'].toFixed(2)}"</span>
|
||||
</div>
|
||||
)}
|
||||
{day.snow?.['3h'] > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CloudSnow className="w-3 h-3 text-blue-300" />
|
||||
<span className="text-xs text-white">{day.snow['3h'].toFixed(2)}"</span>
|
||||
</div>
|
||||
)}
|
||||
{!day.rain?.['3h'] && !day.snow?.['3h'] && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Umbrella className="w-3 h-3 text-slate-300" />
|
||||
<span className="text-xs text-white">0"</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full transition-opacity duration-300 ${mounted ? 'opacity-100' : 'opacity-0'}">
|
||||
{/* Time Display */}
|
||||
<Card className="bg-gradient-to-br mb-[7px] from-indigo-900 to-blue-800 backdrop-blur-sm dark:bg-slate-800 px-1 py-2 w-full hover:scale-[1.02] transition-transform duration-300">
|
||||
<CardContent className="p-3 h-[106px] flex items-center">
|
||||
<div className="flex justify-center items-baseline w-full">
|
||||
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
|
||||
<span className="text-6xl font-bold text-white">{hours}</span>
|
||||
<span className="text-6xl font-bold text-white">:</span>
|
||||
<span className="text-6xl font-bold text-white">{minutes}</span>
|
||||
<span className="text-lg font-medium text-white/90 ml-1">{ampm}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Date and Weather Display */}
|
||||
<div className="h-[125px] mb-[6px] grid grid-cols-2 gap-2 w-full">
|
||||
<Card className="h-full bg-gradient-to-br from-violet-900 to-purple-800 backdrop-blur-sm flex items-center justify-center">
|
||||
<CardContent className="h-full p-0">
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<span className="text-6xl font-bold text-white">
|
||||
{dateInfo.day}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-white mt-2">
|
||||
{dateInfo.weekday}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{weather?.main && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Card className={cn(
|
||||
getWeatherBackground(
|
||||
weather.weather[0]?.id,
|
||||
datetime.getHours() >= 18 || datetime.getHours() < 6
|
||||
),
|
||||
"flex items-center justify-center cursor-pointer hover:brightness-110 transition-all relative backdrop-blur-sm"
|
||||
)}>
|
||||
<CardContent className="h-full p-3">
|
||||
<div className="flex flex-col items-center">
|
||||
{getWeatherIcon(weather.weather[0]?.id, datetime)}
|
||||
<span className="text-3xl font-bold ml-1 mt-2 text-white">
|
||||
{Math.round(weather.main.temp)}°
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
{weather.alerts && (
|
||||
<div className="absolute top-1 right-1">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[450px] bg-gradient-to-br from-slate-800 to-slate-700 border-slate-600"
|
||||
align="start"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
style={{
|
||||
transform: `scale(${scaleFactor})`,
|
||||
transformOrigin: 'left top'
|
||||
}}
|
||||
>
|
||||
{weather.alerts && (
|
||||
<Alert variant="warning" className="mb-3 bg-amber-900/50 border-amber-700">
|
||||
<AlertTriangle className="h-3 w-3 text-amber-500" />
|
||||
<AlertDescription className="text-xs text-amber-200">
|
||||
{weather.alerts[0].event}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<WeatherDetails />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calendar Display */}
|
||||
<Card className="w-full bg-white dark:bg-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<CalendarComponent
|
||||
selected={datetime}
|
||||
className="w-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateTimeWeatherDisplay;
|
||||
1622
inventory/src/components/dashboard/EventFeed.jsx
Normal file
1622
inventory/src/components/dashboard/EventFeed.jsx
Normal file
File diff suppressed because it is too large
Load Diff
580
inventory/src/components/dashboard/GorgiasOverview.jsx
Normal file
580
inventory/src/components/dashboard/GorgiasOverview.jsx
Normal file
@@ -0,0 +1,580 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/dashboard/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "@/components/dashboard/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/dashboard/ui/table";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
import {
|
||||
Clock,
|
||||
Star,
|
||||
MessageSquare,
|
||||
Mail,
|
||||
Send,
|
||||
Loader2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Zap,
|
||||
Timer,
|
||||
BarChart3,
|
||||
ClipboardCheck,
|
||||
} from "lucide-react";
|
||||
import axios from "axios";
|
||||
|
||||
const TIME_RANGES = {
|
||||
"today": "Today",
|
||||
"7": "Last 7 Days",
|
||||
"14": "Last 14 Days",
|
||||
"30": "Last 30 Days",
|
||||
"90": "Last 90 Days",
|
||||
};
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
};
|
||||
|
||||
const getDateRange = (days) => {
|
||||
// Create date in Eastern Time
|
||||
const now = new Date();
|
||||
const easternTime = new Date(
|
||||
now.toLocaleString("en-US", { timeZone: "America/New_York" })
|
||||
);
|
||||
|
||||
if (days === "today") {
|
||||
// For today, set the range to be the current day in Eastern Time
|
||||
const start = new Date(easternTime);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
const end = new Date(easternTime);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
return {
|
||||
start_datetime: start.toISOString(),
|
||||
end_datetime: end.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
// For other periods, calculate from end of previous day
|
||||
const end = new Date(easternTime);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
const start = new Date(easternTime);
|
||||
start.setDate(start.getDate() - Number(days));
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
return {
|
||||
start_datetime: start.toISOString(),
|
||||
end_datetime: end.toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
const MetricCard = ({
|
||||
title,
|
||||
value,
|
||||
delta,
|
||||
suffix = "",
|
||||
icon: Icon,
|
||||
colorClass = "blue",
|
||||
more_is_better = true,
|
||||
loading = false,
|
||||
}) => {
|
||||
const getDeltaColor = (d) => {
|
||||
if (d === 0) return "text-gray-600 dark:text-gray-400";
|
||||
const isPositive = d > 0;
|
||||
return isPositive === more_is_better
|
||||
? "text-green-600 dark:text-green-500"
|
||||
: "text-red-600 dark:text-red-500";
|
||||
};
|
||||
|
||||
const formatDelta = (d) => {
|
||||
if (d === undefined || d === null) return null;
|
||||
if (d === 0) return "0";
|
||||
return Math.abs(d) + suffix;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardContent className="pt-6 h-full">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
{loading ? (
|
||||
<>
|
||||
<Skeleton className="h-4 w-24 mb-4 dark:bg-gray-700" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Skeleton className="h-8 w-20 dark:bg-gray-700" />
|
||||
<Skeleton className="h-4 w-12 dark:bg-gray-700" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-2xl font-bold">
|
||||
{typeof value === "number"
|
||||
? value.toLocaleString() + suffix
|
||||
: value}
|
||||
</p>
|
||||
{delta !== undefined && delta !== 0 && (
|
||||
<div className={`flex items-center ${getDeltaColor(delta)}`}>
|
||||
{delta > 0 ? (
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
{formatDelta(delta)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!loading && Icon && (
|
||||
<Icon className={`h-5 w-5 flex-shrink-0 ml-2 ${colorClass === "blue" ? "text-blue-500" :
|
||||
colorClass === "green" ? "text-green-500" :
|
||||
colorClass === "purple" ? "text-purple-500" :
|
||||
colorClass === "indigo" ? "text-indigo-500" :
|
||||
colorClass === "orange" ? "text-orange-500" :
|
||||
colorClass === "teal" ? "text-teal-500" :
|
||||
colorClass === "cyan" ? "text-cyan-500" :
|
||||
"text-blue-500"}`} />
|
||||
)}
|
||||
{loading && (
|
||||
<Skeleton className="h-5 w-5 rounded-full dark:bg-gray-700" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const SkeletonMetricCard = () => (
|
||||
<Card className="h-full">
|
||||
<CardContent className="pt-6 h-full">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Skeleton className="h-4 w-24 mb-4 bg-muted" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Skeleton className="h-8 w-20 bg-muted" />
|
||||
<Skeleton className="h-4 w-12 bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-5 w-5 rounded-full bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const TableSkeleton = () => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
|
||||
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
|
||||
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
|
||||
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<TableRow key={i} className="dark:border-gray-800">
|
||||
<TableCell><Skeleton className="h-4 w-32 bg-muted" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-4 w-12 ml-auto bg-muted" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-4 w-12 ml-auto bg-muted" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
const GorgiasOverview = () => {
|
||||
const [timeRange, setTimeRange] = useState("7");
|
||||
const [data, setData] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const filters = getDateRange(timeRange);
|
||||
|
||||
try {
|
||||
const [overview, channelStats, agentStats, satisfaction] =
|
||||
await Promise.all([
|
||||
axios.post('/api/gorgias/stats/overview', filters)
|
||||
.then(res => res.data?.data?.data?.data || []),
|
||||
axios.post('/api/gorgias/stats/tickets-created-per-channel', filters)
|
||||
.then(res => res.data?.data?.data?.data?.lines || []),
|
||||
axios.post('/api/gorgias/stats/tickets-closed-per-agent', filters)
|
||||
.then(res => res.data?.data?.data?.data?.lines || []),
|
||||
axios.post('/api/gorgias/stats/satisfaction-surveys', filters)
|
||||
.then(res => res.data?.data?.data?.data || []),
|
||||
]);
|
||||
|
||||
console.log('Raw API responses:', {
|
||||
overview,
|
||||
channelStats,
|
||||
agentStats,
|
||||
satisfaction,
|
||||
});
|
||||
|
||||
setData({
|
||||
overview,
|
||||
channels: channelStats,
|
||||
agents: agentStats,
|
||||
satisfaction,
|
||||
});
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Error loading stats:", err);
|
||||
const errorMessage = err.response?.data?.error || err.message;
|
||||
setError(errorMessage);
|
||||
if (err.response?.status === 401) {
|
||||
setError('Authentication failed. Please check your Gorgias API credentials.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
// Set up auto-refresh every 5 minutes
|
||||
const interval = setInterval(loadStats, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadStats]);
|
||||
|
||||
// Convert overview array to stats format
|
||||
const stats = (data.overview || []).reduce((acc, item) => {
|
||||
acc[item.name] = {
|
||||
value: item.value || 0,
|
||||
delta: item.delta || 0,
|
||||
type: item.type,
|
||||
more_is_better: item.more_is_better
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
console.log('Processed stats:', stats);
|
||||
|
||||
// Process satisfaction data
|
||||
const satisfactionStats = (data.satisfaction || []).reduce((acc, item) => {
|
||||
if (item.name !== 'response_distribution') {
|
||||
acc[item.name] = {
|
||||
value: item.value || 0,
|
||||
delta: item.delta || 0,
|
||||
type: item.type,
|
||||
more_is_better: item.more_is_better
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
console.log('Processed satisfaction stats:', satisfactionStats);
|
||||
|
||||
// Process channel data
|
||||
const channels = data.channels?.map(line => ({
|
||||
name: line[0]?.value || '',
|
||||
total: line[1]?.value || 0,
|
||||
percentage: line[2]?.value || 0,
|
||||
delta: line[3]?.value || 0
|
||||
})) || [];
|
||||
|
||||
console.log('Processed channels:', channels);
|
||||
|
||||
// Process agent data
|
||||
const agents = data.agents?.map(line => ({
|
||||
name: line[0]?.value || '',
|
||||
closed: line[1]?.value || 0,
|
||||
rating: line[2]?.value,
|
||||
percentage: line[3]?.value || 0,
|
||||
delta: line[4]?.value || 0
|
||||
})) || [];
|
||||
|
||||
console.log('Processed agents:', agents);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20">
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Customer Service
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={timeRange}
|
||||
onValueChange={(value) => setTimeRange(value)}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] bg-white dark:bg-gray-800">
|
||||
<SelectValue placeholder="Select range">
|
||||
{TIME_RANGES[timeRange]}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[
|
||||
["today", "Today"],
|
||||
["7", "Last 7 Days"],
|
||||
["14", "Last 14 Days"],
|
||||
["30", "Last 30 Days"],
|
||||
["90", "Last 90 Days"],
|
||||
].map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Message & Response Metrics */}
|
||||
{loading ? (
|
||||
[...Array(7)].map((_, i) => (
|
||||
<SkeletonMetricCard key={i} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
title="Messages Received"
|
||||
value={stats.total_messages_received?.value}
|
||||
delta={stats.total_messages_received?.delta}
|
||||
icon={Mail}
|
||||
colorClass="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
title="Messages Sent"
|
||||
value={stats.total_messages_sent?.value}
|
||||
delta={stats.total_messages_sent?.delta}
|
||||
icon={Send}
|
||||
colorClass="green"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
title="First Response"
|
||||
value={formatDuration(stats.median_first_response_time?.value)}
|
||||
delta={stats.median_first_response_time?.delta}
|
||||
icon={Zap}
|
||||
colorClass="purple"
|
||||
more_is_better={false}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
title="One-Touch Rate"
|
||||
value={stats.total_one_touch_tickets?.value}
|
||||
delta={stats.total_one_touch_tickets?.delta}
|
||||
suffix="%"
|
||||
icon={BarChart3}
|
||||
colorClass="indigo"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
title="Customer Satisfaction"
|
||||
value={`${satisfactionStats.average_rating?.value}/5`}
|
||||
delta={satisfactionStats.average_rating?.delta}
|
||||
suffix="%"
|
||||
icon={Star}
|
||||
colorClass="orange"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
title="Survey Response Rate"
|
||||
value={satisfactionStats.response_rate?.value}
|
||||
delta={satisfactionStats.response_rate?.delta}
|
||||
suffix="%"
|
||||
icon={ClipboardCheck}
|
||||
colorClass="pink"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
title="Resolution Time"
|
||||
value={formatDuration(stats.median_resolution_time?.value)}
|
||||
delta={stats.median_resolution_time?.delta}
|
||||
icon={Timer}
|
||||
colorClass="teal"
|
||||
more_is_better={false}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Channel Distribution */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Channel Distribution
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
||||
{loading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-left font-medium text-gray-900 dark:text-gray-100">Channel</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Total</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">%</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Change</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channels
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.map((channel, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800 hover:bg-muted/50 transition-colors">
|
||||
<TableCell className="text-gray-900 dark:text-gray-100">
|
||||
{channel.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{channel.total}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{channel.percentage}%
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right ${
|
||||
channel.delta > 0
|
||||
? "text-green-600 dark:text-green-500"
|
||||
: channel.delta < 0
|
||||
? "text-red-600 dark:text-red-500"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{channel.delta !== 0 && (
|
||||
<>
|
||||
{channel.delta > 0 ? (
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
)}
|
||||
<span>{Math.abs(channel.delta)}%</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Agent Performance */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Agent Performance
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
||||
{loading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-left font-medium text-gray-900 dark:text-gray-100">Agent</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Closed</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Rating</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Change</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{agents
|
||||
.filter((agent) => agent.name !== "Unassigned")
|
||||
.map((agent, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800 hover:bg-muted/50 transition-colors">
|
||||
<TableCell className="text-gray-900 dark:text-gray-100">
|
||||
{agent.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{agent.closed}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{agent.rating ? `${agent.rating}/5` : "-"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right ${
|
||||
agent.delta > 0
|
||||
? "text-green-600 dark:text-green-500"
|
||||
: agent.delta < 0
|
||||
? "text-red-600 dark:text-red-500"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{agent.delta !== 0 && (
|
||||
<>
|
||||
{agent.delta > 0 ? (
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
)}
|
||||
<span>{Math.abs(agent.delta)}%</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default GorgiasOverview;
|
||||
376
inventory/src/components/dashboard/Header.jsx
Normal file
376
inventory/src/components/dashboard/Header.jsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent } from "@/components/dashboard/ui/card";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Sun,
|
||||
Cloud,
|
||||
CloudRain,
|
||||
CloudDrizzle,
|
||||
CloudSnow,
|
||||
CloudLightning,
|
||||
CloudFog,
|
||||
CloudSun,
|
||||
CircleAlert,
|
||||
Tornado,
|
||||
Haze,
|
||||
Moon,
|
||||
Monitor,
|
||||
Wind,
|
||||
Droplets,
|
||||
ThermometerSun,
|
||||
ThermometerSnowflake,
|
||||
Sunrise,
|
||||
Sunset,
|
||||
AlertTriangle,
|
||||
Umbrella,
|
||||
} from "lucide-react";
|
||||
import { useScroll } from "@/contexts/DashboardScrollContext";
|
||||
import { useTheme } from "@/components/dashboard/theme/ThemeProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/dashboard/ui/tooltip";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/dashboard/ui/popover";
|
||||
import { Alert, AlertDescription } from "@/components/dashboard/ui/alert";
|
||||
|
||||
const CraftsIcon = () => (
|
||||
<svg viewBox="0 0 2687 3338" className="w-6 h-6" aria-hidden="true">
|
||||
<path
|
||||
fill="white"
|
||||
d="M911.230469 1807.75C974.730469 1695.5 849.919922 1700.659912 783.610352 1791.25C645.830078 1979.439941 874.950195 2120.310059 1112.429688 2058.800049C1201.44043 2035.72998 1278.759766 2003.080078 1344.580078 1964.159912C1385.389648 1940.040039 1380.900391 1926.060059 1344.580078 1935.139893C1294.040039 1947.800049 1261.69043 1953.73999 1177.700195 1966.97998C1084.719727 1981.669922 832.790039 1984.22998 911.230469 1807.75M1046.799805 1631.389893C1135.280273 1670.419922 1139.650391 1624.129883 1056.980469 1562.070068C925.150391 1463.110107 787.360352 1446.379883 661.950195 1478.280029C265.379883 1579.179932 67.740234 2077.050049 144.099609 2448.399902C357.860352 3487.689941 1934.570313 3457.959961 2143.030273 2467.540039C2204.700195 2174.439941 2141.950195 1852.780029 1917.990234 1665.149902C1773.219727 1543.870117 1575.009766 1536.659912 1403.599609 1591.72998C1380.639648 1599.110107 1381.410156 1616.610107 1403.599609 1612.379883C1571.25 1596.040039 1750.790039 1606 1856.75 1745.280029C2038.769531 1984.459961 2052.570313 2274.080078 1974.629883 2511.209961C1739.610352 3226.25 640.719727 3226.540039 401.719727 2479.26001C308.040039 2186.350098 400.299805 1788.800049 690 1639.100098C785.830078 1589.590088 907.040039 1569.709961 1046.799805 1631.389893Z"
|
||||
/>
|
||||
<path
|
||||
fill="white"
|
||||
d="M1270.089844 1727.72998C1292.240234 1431.47998 1284.94043 952.430176 1257.849609 717.390137C1235.679688 525.310059 1166.200195 416.189941 1093.629883 349.390137C1157.620117 313.180176 1354.129883 485.680176 1447.830078 603.350098C1790.870117 1034.100098 2235.580078 915.060059 2523.480469 721.129883C2569.120117 680.51001 2592.900391 654.030029 2523.480469 651.339844C2260.400391 615.330078 2115 463.060059 1947.530273 293.890137C1672.870117 16.459961 1143.719727 162.169922 1033.969727 303.040039C999.339844 280.299805 966.849609 265 941.709961 252.419922C787.139648 175.160156 670.049805 223.580078 871.780273 341.569824C962.599609 394.689941 1089.849609 483.48999 1168.230469 799.589844C1222.370117 1018.040039 1230.009766 1423.919922 1242.360352 1728.379883C1247 1761.850098 1264.799805 1759.629883 1270.089844 1727.72998"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Header = () => {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [weather, setWeather] = useState(null);
|
||||
const [forecast, setForecast] = useState(null);
|
||||
const { isStuck } = useScroll();
|
||||
const { theme, systemTheme, toggleTheme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWeatherData = async () => {
|
||||
try {
|
||||
const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY;
|
||||
const [weatherResponse, forecastResponse] = await Promise.all([
|
||||
fetch(
|
||||
`https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
||||
),
|
||||
fetch(
|
||||
`https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
||||
)
|
||||
]);
|
||||
|
||||
const weatherData = await weatherResponse.json();
|
||||
const forecastData = await forecastResponse.json();
|
||||
|
||||
setWeather(weatherData);
|
||||
|
||||
// Process forecast data to get daily forecasts
|
||||
const dailyForecasts = forecastData.list.reduce((acc, item) => {
|
||||
const date = new Date(item.dt * 1000).toLocaleDateString();
|
||||
if (!acc[date]) {
|
||||
acc[date] = {
|
||||
...item,
|
||||
precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0,
|
||||
pop: item.pop * 100
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
setForecast(Object.values(dailyForecasts).slice(0, 5));
|
||||
} catch (error) {
|
||||
console.error("Error fetching weather:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchWeatherData();
|
||||
const weatherTimer = setInterval(fetchWeatherData, 300000);
|
||||
return () => clearInterval(weatherTimer);
|
||||
}, []);
|
||||
|
||||
const getWeatherIcon = (weatherCode, currentTime, small = false) => {
|
||||
if (!weatherCode) return <CircleAlert className={cn(small ? "w-6 h-6" : "w-7 h-7", "text-red-500")} />;
|
||||
|
||||
const code = parseInt(weatherCode, 10);
|
||||
const iconProps = small ? "w-6 h-6" : "w-7 h-7";
|
||||
|
||||
switch (true) {
|
||||
case code >= 200 && code < 300:
|
||||
return <CloudLightning className={cn(iconProps, "text-gray-700")} />;
|
||||
case code >= 300 && code < 500:
|
||||
return <CloudDrizzle className={cn(iconProps, "text-blue-600")} />;
|
||||
case code >= 500 && code < 600:
|
||||
return <CloudRain className={cn(iconProps, "text-blue-600")} />;
|
||||
case code >= 600 && code < 700:
|
||||
return <CloudSnow className={cn(iconProps, "text-blue-400")} />;
|
||||
case code >= 700 && code < 721:
|
||||
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
|
||||
case code === 721:
|
||||
return <Haze className={cn(iconProps, "text-gray-700")} />;
|
||||
case code >= 722 && code < 781:
|
||||
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
|
||||
case code === 781:
|
||||
return <Tornado className={cn(iconProps, "text-gray-700")} />;
|
||||
case code === 800:
|
||||
return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? (
|
||||
<Sun className={cn(iconProps, "text-yellow-500")} />
|
||||
) : (
|
||||
<Moon className={cn(iconProps, "text-gray-300")} />
|
||||
);
|
||||
case code >= 800 && code < 803:
|
||||
return <CloudSun className={cn(iconProps, "text-gray-600")} />;
|
||||
case code >= 803:
|
||||
return <Cloud className={cn(iconProps, "text-gray-600")} />;
|
||||
default:
|
||||
return <CircleAlert className={cn(iconProps, "text-red-500")} />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return '--:--';
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
};
|
||||
|
||||
const WeatherDetails = () => (
|
||||
<div className="space-y-4 p-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Card className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ThermometerSun className="w-5 h-5 text-orange-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">High</span>
|
||||
<span className="text-sm font-bold">{Math.round(weather.main.temp_max)}°F</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ThermometerSnowflake className="w-5 h-5 text-blue-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">Low</span>
|
||||
<span className="text-sm font-bold">{Math.round(weather.main.temp_min)}°F</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Droplets className="w-5 h-5 text-blue-400" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">Humidity</span>
|
||||
<span className="text-sm font-bold">{weather.main.humidity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Wind className="w-5 h-5 text-gray-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">Wind</span>
|
||||
<span className="text-sm font-bold">{Math.round(weather.wind.speed)} mph</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Sunrise className="w-5 h-5 text-yellow-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">Sunrise</span>
|
||||
<span className="text-sm font-bold">{formatTime(weather.sys?.sunrise)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Sunset className="w-5 h-5 text-orange-400" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">Sunset</span>
|
||||
<span className="text-sm font-bold">{formatTime(weather.sys?.sunset)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{forecast && (
|
||||
<div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{forecast.map((day, index) => (
|
||||
<Card key={index} className="p-2">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{new Date(day.dt * 1000).toLocaleDateString('en-US', { weekday: 'short' })}
|
||||
</span>
|
||||
{getWeatherIcon(day.weather[0].id, new Date(day.dt * 1000), true)}
|
||||
<div className="flex justify-center gap-1 items-baseline w-full">
|
||||
<span className="text-sm font-bold">
|
||||
{Math.round(day.main.temp_max)}°
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{Math.round(day.main.temp_min)}°
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 w-full pt-1">
|
||||
{day.rain?.['3h'] > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CloudRain className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-xs">{day.rain['3h'].toFixed(2)}"</span>
|
||||
</div>
|
||||
)}
|
||||
{day.snow?.['3h'] > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CloudSnow className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-xs">{day.snow['3h'].toFixed(2)}"</span>
|
||||
</div>
|
||||
)}
|
||||
{!day.rain?.['3h'] && !day.snow?.['3h'] && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Umbrella className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-xs">0"</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const formatDate = (date) =>
|
||||
date.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const formatTimeDisplay = (date) => {
|
||||
const hours = date.getHours();
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
const period = hours >= 12 ? "PM" : "AM";
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes}:${seconds} ${period}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full bg-white dark:bg-gray-900 shadow-sm",
|
||||
isStuck ? "rounded-b-lg border-b-1" : "border-b-0 rounded-b-none"
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col justify-between lg:flex-row items-center sm:items-center flex-wrap">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex space-x-2">
|
||||
<div
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
"bg-gradient-to-r from-blue-500 to-blue-600 p-3 rounded-lg shadow-md cursor-pointer hover:opacity-90 transition-opacity",
|
||||
theme === "light" && "ring-1 ring-yellow-300",
|
||||
theme === "dark" && "ring-1 ring-purple-300",
|
||||
"ring-offset-2 ring-offset-white dark:ring-offset-gray-900"
|
||||
)}
|
||||
>
|
||||
<CraftsIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-blue-400 bg-clip-text text-transparent">
|
||||
ACOT Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-left sm:items-center justify-center flex-wrap mt-2 sm:mt-0">
|
||||
{weather?.main && (
|
||||
<>
|
||||
<div className="flex-col items-center text-center">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="items-center justify-center space-x-2 rounded-lg px-4 hidden sm:flex cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors p-2">
|
||||
{getWeatherIcon(weather.weather[0]?.id, currentTime)}
|
||||
<div>
|
||||
<p className="text-xl font-bold tracking-tight dark:text-gray-100">
|
||||
{Math.round(weather.main.temp)}° F
|
||||
</p>
|
||||
</div>
|
||||
{weather.alerts && (
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 ml-1" />
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[450px]" align="end" side="bottom" sideOffset={5}>
|
||||
{weather.alerts && (
|
||||
<Alert variant="warning" className="mb-3">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<AlertDescription className="text-xs">
|
||||
{weather.alerts[0].event}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<WeatherDetails />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="h-10 w-px bg-gradient-to-b from-gray-200 to-gray-200 dark:from-gray-700 dark:to-gray-700 hidden sm:block"></div>
|
||||
<div className="flex items-center space-x-1 sm:space-x-3 rounded-lg px-4 py-2">
|
||||
<Calendar className="w-5 h-5 text-green-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm sm:text-xl font-bold tracking-tight p-0 dark:text-gray-100">
|
||||
{formatDate(currentTime)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-10 w-px bg-gradient-to-b from-gray-200 to-gray-200 dark:from-gray-700 dark:to-gray-700 hidden sm:block"></div>
|
||||
<div className="flex items-center space-x-1 sm:space-x-3 rounded-lg px-4 py-2">
|
||||
<Clock className="w-5 h-5 text-blue-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-md sm:text-xl font-bold tracking-tight tabular-nums dark:text-gray-100 mr-2">
|
||||
{formatTimeDisplay(currentTime)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
477
inventory/src/components/dashboard/KlaviyoCampaigns.jsx
Normal file
477
inventory/src/components/dashboard/KlaviyoCampaigns.jsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/dashboard/ui/tooltip";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/dashboard/ui/select";
|
||||
import { Button } from "@/components/dashboard/ui/button";
|
||||
import { TIME_RANGES } from "@/lib/dashboard/constants";
|
||||
import { Mail, MessageSquare, ArrowUpDown, BookOpen } from "lucide-react";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
|
||||
// Helper functions for formatting
|
||||
const formatRate = (value, isSMS = false, hideForSMS = false) => {
|
||||
if (isSMS && hideForSMS) return "N/A";
|
||||
if (typeof value !== "number") return "0.0%";
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
if (typeof value !== "number") return "$0";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// Loading skeleton component
|
||||
const TableSkeleton = () => (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
|
||||
<Skeleton className="h-8 w-24 bg-muted" />
|
||||
</th>
|
||||
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
|
||||
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
|
||||
</th>
|
||||
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
|
||||
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
|
||||
</th>
|
||||
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
|
||||
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
|
||||
</th>
|
||||
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
|
||||
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
|
||||
</th>
|
||||
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
|
||||
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{[...Array(15)].map((_, i) => (
|
||||
<tr key={i} className="hover:bg-muted/50 transition-colors">
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4 bg-muted" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48 bg-muted" />
|
||||
<Skeleton className="h-3 w-64 bg-muted" />
|
||||
<Skeleton className="h-3 w-32 bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Skeleton className="h-4 w-16 bg-muted" />
|
||||
<Skeleton className="h-3 w-24 bg-muted" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Skeleton className="h-4 w-16 bg-muted" />
|
||||
<Skeleton className="h-3 w-24 bg-muted" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Skeleton className="h-4 w-16 bg-muted" />
|
||||
<Skeleton className="h-3 w-24 bg-muted" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Skeleton className="h-4 w-16 bg-muted" />
|
||||
<Skeleton className="h-3 w-24 bg-muted" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Skeleton className="h-4 w-16 bg-muted" />
|
||||
<Skeleton className="h-3 w-24 bg-muted" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
// Error alert component
|
||||
const ErrorAlert = ({ description }) => (
|
||||
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20">
|
||||
{description}
|
||||
</div>
|
||||
);
|
||||
|
||||
// MetricCell component for displaying campaign metrics
|
||||
const MetricCell = ({
|
||||
value,
|
||||
count,
|
||||
isMonetary = false,
|
||||
showConversionRate = false,
|
||||
totalRecipients = 0,
|
||||
isSMS = false,
|
||||
hideForSMS = false,
|
||||
}) => {
|
||||
if (isSMS && hideForSMS) {
|
||||
return (
|
||||
<td className="p-2 text-center">
|
||||
<div className="text-muted-foreground text-lg font-semibold">N/A</div>
|
||||
<div className="text-muted-foreground text-sm">-</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<td className="p-2 text-center">
|
||||
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
|
||||
{isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{count?.toLocaleString() || 0} {count === 1 ? "recipient" : "recipients"}
|
||||
{showConversionRate &&
|
||||
totalRecipients > 0 &&
|
||||
` (${((count / totalRecipients) * 100).toFixed(2)}%)`}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
const KlaviyoCampaigns = ({ className }) => {
|
||||
const [campaigns, setCampaigns] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedChannels, setSelectedChannels] = useState({ email: true, sms: true, blog: true });
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState("last7days");
|
||||
const [sortConfig, setSortConfig] = useState({
|
||||
key: "send_time",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
const handleSort = (key) => {
|
||||
setSortConfig((prev) => ({
|
||||
key,
|
||||
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchCampaigns = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/klaviyo/reporting/campaigns/${selectedTimeRange}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch campaigns: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setCampaigns(data.data || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Error fetching campaigns:", err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaigns();
|
||||
const interval = setInterval(fetchCampaigns, 10 * 60 * 1000); // Refresh every 10 minutes
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedTimeRange]); // Only refresh when time range changes
|
||||
|
||||
// Sort campaigns
|
||||
const sortedCampaigns = [...campaigns].sort((a, b) => {
|
||||
const direction = sortConfig.direction === "desc" ? -1 : 1;
|
||||
|
||||
switch (sortConfig.key) {
|
||||
case "send_time":
|
||||
return direction * (DateTime.fromISO(a.send_time) - DateTime.fromISO(b.send_time));
|
||||
case "delivery_rate":
|
||||
return direction * ((a.stats.delivery_rate || 0) - (b.stats.delivery_rate || 0));
|
||||
case "open_rate":
|
||||
return direction * ((a.stats.open_rate || 0) - (b.stats.open_rate || 0));
|
||||
case "click_rate":
|
||||
return direction * ((a.stats.click_rate || 0) - (b.stats.click_rate || 0));
|
||||
case "click_to_open_rate":
|
||||
return direction * ((a.stats.click_to_open_rate || 0) - (b.stats.click_to_open_rate || 0));
|
||||
case "conversion_value":
|
||||
return direction * ((a.stats.conversion_value || 0) - (b.stats.conversion_value || 0));
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter campaigns by search term and channels
|
||||
const filteredCampaigns = sortedCampaigns.filter(
|
||||
(campaign) => {
|
||||
const isBlog = campaign?.name?.includes("_Blog");
|
||||
const channelType = isBlog ? "blog" : campaign?.channel;
|
||||
return campaign?.name?.toLowerCase().includes((searchTerm || "").toLowerCase()) &&
|
||||
selectedChannels[channelType];
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
<Skeleton className="h-6 w-48 bg-muted" />
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex ml-1 gap-1 items-center">
|
||||
<Skeleton className="h-8 w-20 bg-muted" />
|
||||
<Skeleton className="h-8 w-20 bg-muted" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-[130px] bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
|
||||
<TableSkeleton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
{error && <ErrorAlert description={error} />}
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Klaviyo Campaigns
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex ml-1 gap-1 items-center">
|
||||
<Button
|
||||
variant={selectedChannels.email ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedChannels(prev => {
|
||||
if (prev.email && Object.values(prev).filter(Boolean).length === 1) {
|
||||
// If only email is selected, show all
|
||||
return { email: true, sms: true, blog: true };
|
||||
}
|
||||
// Show only email
|
||||
return { email: true, sms: false, blog: false };
|
||||
})}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Email</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedChannels.sms ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedChannels(prev => {
|
||||
if (prev.sms && Object.values(prev).filter(Boolean).length === 1) {
|
||||
// If only SMS is selected, show all
|
||||
return { email: true, sms: true, blog: true };
|
||||
}
|
||||
// Show only SMS
|
||||
return { email: false, sms: true, blog: false };
|
||||
})}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">SMS</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedChannels.blog ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedChannels(prev => {
|
||||
if (prev.blog && Object.values(prev).filter(Boolean).length === 1) {
|
||||
// If only blog is selected, show all
|
||||
return { email: true, sms: true, blog: true };
|
||||
}
|
||||
// Show only blog
|
||||
return { email: false, sms: false, blog: true };
|
||||
})}
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Blog</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="Select time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_RANGES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleSort("send_time")}
|
||||
className="w-full justify-start h-8"
|
||||
>
|
||||
Campaign
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "delivery_rate" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("delivery_rate")}
|
||||
className="w-full justify-center h-8"
|
||||
>
|
||||
Delivery
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "open_rate" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("open_rate")}
|
||||
className="w-full justify-center h-8"
|
||||
>
|
||||
Opens
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "click_rate" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("click_rate")}
|
||||
className="w-full justify-center h-8"
|
||||
>
|
||||
Clicks
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "click_to_open_rate" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("click_to_open_rate")}
|
||||
className="w-full justify-center h-8"
|
||||
>
|
||||
CTR
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "conversion_value" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("conversion_value")}
|
||||
className="w-full justify-center h-8"
|
||||
>
|
||||
Orders
|
||||
</Button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{filteredCampaigns.map((campaign) => (
|
||||
<tr
|
||||
key={campaign.id}
|
||||
className="hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<td className="p-2 align-top">
|
||||
<div className="flex items-center gap-2">
|
||||
{campaign.name?.includes("_Blog") ? (
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : campaign.channel === 'sms' ? (
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{campaign.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground truncate max-w-[300px]">
|
||||
{campaign.subject}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{campaign.send_time
|
||||
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
|
||||
: "No date"}
|
||||
</div>
|
||||
</td>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="break-words bg-white dark:bg-gray-900/60 backdrop-blur-sm text-gray-900 dark:text-gray-100 border dark:border-gray-800"
|
||||
>
|
||||
<p className="font-medium">{campaign.name}</p>
|
||||
<p>{campaign.subject}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{campaign.send_time
|
||||
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
|
||||
: "No date"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<MetricCell
|
||||
value={campaign.stats.delivery_rate}
|
||||
count={campaign.stats.delivered}
|
||||
totalRecipients={campaign.stats.recipients}
|
||||
isSMS={campaign.channel === 'sms'}
|
||||
/>
|
||||
<MetricCell
|
||||
value={campaign.stats.open_rate}
|
||||
count={campaign.stats.opens_unique}
|
||||
totalRecipients={campaign.stats.recipients}
|
||||
isSMS={campaign.channel === 'sms'}
|
||||
hideForSMS={true}
|
||||
/>
|
||||
<MetricCell
|
||||
value={campaign.stats.click_rate}
|
||||
count={campaign.stats.clicks_unique}
|
||||
totalRecipients={campaign.stats.recipients}
|
||||
isSMS={campaign.channel === 'sms'}
|
||||
/>
|
||||
<MetricCell
|
||||
value={campaign.stats.click_to_open_rate}
|
||||
count={campaign.stats.clicks_unique}
|
||||
totalRecipients={campaign.stats.opens_unique}
|
||||
isSMS={campaign.channel === 'sms'}
|
||||
hideForSMS={true}
|
||||
/>
|
||||
<MetricCell
|
||||
value={campaign.stats.conversion_value}
|
||||
count={campaign.stats.conversion_uniques}
|
||||
isMonetary={true}
|
||||
showConversionRate={true}
|
||||
totalRecipients={campaign.stats.recipients}
|
||||
isSMS={campaign.channel === 'sms'}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default KlaviyoCampaigns;
|
||||
28
inventory/src/components/dashboard/LockButton.jsx
Normal file
28
inventory/src/components/dashboard/LockButton.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Button } from "@/components/dashboard/ui/button";
|
||||
import { Lock } from "lucide-react";
|
||||
|
||||
const LockButton = () => {
|
||||
const handleLock = () => {
|
||||
// Remove PIN verification from session storage
|
||||
sessionStorage.removeItem('pinVerified');
|
||||
// Reset attempt count
|
||||
localStorage.removeItem('pinAttempts');
|
||||
localStorage.removeItem('lastAttemptTime');
|
||||
// Force reload to show PIN screen
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onClick={handleLock}
|
||||
>
|
||||
<Lock className="h-5 w-5" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default LockButton;
|
||||
737
inventory/src/components/dashboard/MetaCampaigns.jsx
Normal file
737
inventory/src/components/dashboard/MetaCampaigns.jsx
Normal file
@@ -0,0 +1,737 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/dashboard/ui/tooltip";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/dashboard/ui/select";
|
||||
import {
|
||||
Instagram,
|
||||
Loader2,
|
||||
Users,
|
||||
DollarSign,
|
||||
Eye,
|
||||
Repeat,
|
||||
MousePointer,
|
||||
BarChart,
|
||||
Target,
|
||||
ShoppingCart,
|
||||
MessageCircle,
|
||||
Hash,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/dashboard/ui/button";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
|
||||
// Helper functions for formatting
|
||||
const formatCurrency = (value, decimalPlaces = 2) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
}).format(value || 0);
|
||||
|
||||
const formatNumber = (value, decimalPlaces = 0) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
}).format(value || 0);
|
||||
};
|
||||
|
||||
const formatPercent = (value, decimalPlaces = 2) =>
|
||||
`${(value || 0).toFixed(decimalPlaces)}%`;
|
||||
|
||||
const summaryCard = (label, value, options = {}) => {
|
||||
const {
|
||||
isMonetary = false,
|
||||
isPercentage = false,
|
||||
decimalPlaces = 0,
|
||||
icon: Icon,
|
||||
iconColor,
|
||||
} = options;
|
||||
|
||||
let displayValue;
|
||||
if (isMonetary) {
|
||||
displayValue = formatCurrency(value, decimalPlaces);
|
||||
} else if (isPercentage) {
|
||||
displayValue = formatPercent(value, decimalPlaces);
|
||||
} else {
|
||||
displayValue = formatNumber(value, decimalPlaces);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardContent className="pt-6 h-full">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||
<p className="text-2xl font-bold">{displayValue}</p>
|
||||
</div>
|
||||
{Icon && (
|
||||
<Icon className={`h-5 w-5 flex-shrink-0 ml-2 ${iconColor || "text-blue-500"}`} />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage = false, decimalPlaces = 0 }) => {
|
||||
const formattedValue = isMonetary
|
||||
? formatCurrency(value, decimalPlaces)
|
||||
: isPercentage
|
||||
? formatPercent(value, decimalPlaces)
|
||||
: formatNumber(value, decimalPlaces);
|
||||
|
||||
return (
|
||||
<td className="p-2 text-center align-top">
|
||||
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
|
||||
{formattedValue}
|
||||
</div>
|
||||
{(label || sublabel) && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{label || sublabel}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
const getActionValue = (campaign, actionType) => {
|
||||
if (actionType === "impressions" || actionType === "reach") {
|
||||
return campaign.metrics[actionType] || 0;
|
||||
}
|
||||
|
||||
const actions = campaign.metrics.actions;
|
||||
if (Array.isArray(actions)) {
|
||||
const action = actions.find((a) => a.action_type === actionType);
|
||||
return action ? parseInt(action.value) || 0 : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const CampaignName = ({ name }) => {
|
||||
if (name.startsWith("Instagram post: ")) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Instagram className="w-4 h-4" />
|
||||
<span>{name.replace("Instagram post: ", "")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span>{name}</span>;
|
||||
};
|
||||
|
||||
const getObjectiveAction = (campaignObjective) => {
|
||||
const objectiveMap = {
|
||||
OUTCOME_AWARENESS: { action_type: "impressions", label: "Impressions" },
|
||||
OUTCOME_ENGAGEMENT: { action_type: "post_engagement", label: "Post Engagements" },
|
||||
OUTCOME_TRAFFIC: { action_type: "link_click", label: "Link Clicks" },
|
||||
OUTCOME_LEADS: { action_type: "lead", label: "Leads" },
|
||||
OUTCOME_SALES: { action_type: "purchase", label: "Purchases" },
|
||||
MESSAGES: { action_type: "messages", label: "Messages" },
|
||||
};
|
||||
|
||||
return objectiveMap[campaignObjective] || { action_type: "link_click", label: "Link Clicks" };
|
||||
};
|
||||
|
||||
const calculateBudget = (campaign) => {
|
||||
if (campaign.daily_budget) {
|
||||
return { value: campaign.daily_budget / 100, type: "day" };
|
||||
}
|
||||
if (campaign.lifetime_budget) {
|
||||
return { value: campaign.lifetime_budget / 100, type: "lifetime" };
|
||||
}
|
||||
|
||||
const adsets = campaign.adsets?.data || [];
|
||||
const dailyTotal = adsets.reduce((sum, adset) => sum + (adset.daily_budget || 0), 0);
|
||||
const lifetimeTotal = adsets.reduce((sum, adset) => sum + (adset.lifetime_budget || 0), 0);
|
||||
|
||||
if (dailyTotal > 0) return { value: dailyTotal / 100, type: "day" };
|
||||
if (lifetimeTotal > 0) return { value: lifetimeTotal / 100, type: "lifetime" };
|
||||
|
||||
return { value: 0, type: "day" };
|
||||
};
|
||||
|
||||
const processMetrics = (campaign) => {
|
||||
const insights = campaign.insights?.data?.[0] || {};
|
||||
const spend = parseFloat(insights.spend || 0);
|
||||
const impressions = parseInt(insights.impressions || 0);
|
||||
const clicks = parseInt(insights.clicks || 0);
|
||||
const reach = parseInt(insights.reach || 0);
|
||||
const cpc = parseFloat(insights.cpc || 0);
|
||||
const ctr = parseFloat(insights.ctr || 0);
|
||||
const cpm = parseFloat(insights.cpm || 0);
|
||||
const frequency = parseFloat(insights.frequency || 0);
|
||||
|
||||
// Purchase value and total purchases
|
||||
const purchaseValue = (insights.action_values || [])
|
||||
.filter(({ action_type }) => action_type === "purchase")
|
||||
.reduce((sum, { value }) => sum + parseFloat(value || 0), 0);
|
||||
|
||||
const totalPurchases = (insights.actions || [])
|
||||
.filter(({ action_type }) => action_type === "purchase")
|
||||
.reduce((sum, { value }) => sum + parseInt(value || 0), 0);
|
||||
|
||||
// Aggregate unique actions
|
||||
const actionMap = new Map();
|
||||
(insights.actions || []).forEach(({ action_type, value }) => {
|
||||
const currentValue = actionMap.get(action_type) || 0;
|
||||
actionMap.set(action_type, currentValue + parseInt(value || 0));
|
||||
});
|
||||
|
||||
const actions = Array.from(actionMap.entries()).map(([action_type, value]) => ({
|
||||
action_type,
|
||||
value,
|
||||
}));
|
||||
|
||||
// Map of cost per action
|
||||
const costPerActionMap = new Map();
|
||||
(insights.cost_per_action_type || []).forEach(({ action_type, value }) => {
|
||||
costPerActionMap.set(action_type, parseFloat(value || 0));
|
||||
});
|
||||
|
||||
// Total post engagements
|
||||
const totalPostEngagements = actionMap.get("post_engagement") || 0;
|
||||
|
||||
return {
|
||||
spend,
|
||||
impressions,
|
||||
clicks,
|
||||
reach,
|
||||
frequency,
|
||||
ctr,
|
||||
cpm,
|
||||
cpc,
|
||||
actions,
|
||||
costPerActionMap,
|
||||
purchaseValue,
|
||||
totalPurchases,
|
||||
totalPostEngagements,
|
||||
};
|
||||
};
|
||||
|
||||
const processCampaignData = (campaign) => {
|
||||
const metrics = processMetrics(campaign);
|
||||
const budget = calculateBudget(campaign);
|
||||
const { action_type, label } = getObjectiveAction(campaign.objective);
|
||||
|
||||
// Get cost per result from costPerActionMap
|
||||
const costPerResult = metrics.costPerActionMap.get(action_type) || 0;
|
||||
|
||||
return {
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
status: campaign.status,
|
||||
objective: label,
|
||||
objectiveActionType: action_type,
|
||||
budget: budget.value,
|
||||
budgetType: budget.type,
|
||||
metrics: {
|
||||
...metrics,
|
||||
costPerResult,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const SkeletonMetricCard = () => (
|
||||
<Card className="h-full">
|
||||
<CardContent className="pt-6 h-full">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Skeleton className="h-4 w-24 mb-4 bg-muted" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Skeleton className="h-8 w-20 bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-5 w-5 rounded-full bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const SkeletonTable = () => (
|
||||
<div className="h-full max-h-[400px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-800">
|
||||
<th className="p-2 sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
|
||||
<Skeleton className="h-4 w-32 bg-muted" />
|
||||
</th>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<th key={i} className="p-2 text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
|
||||
<Skeleton className="h-4 w-20 mx-auto bg-muted" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{[...Array(5)].map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className="hover:bg-muted/50 transition-colors">
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4 bg-muted" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48 bg-muted" />
|
||||
<Skeleton className="h-3 w-64 bg-muted" />
|
||||
<Skeleton className="h-3 w-32 bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{[...Array(8)].map((_, colIndex) => (
|
||||
<td key={colIndex} className="p-2 text-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Skeleton className="h-4 w-16 bg-muted" />
|
||||
<Skeleton className="h-3 w-24 bg-muted" />
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MetaCampaigns = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [campaigns, setCampaigns] = useState([]);
|
||||
const [timeframe, setTimeframe] = useState("7");
|
||||
const [summaryMetrics, setSummaryMetrics] = useState(null);
|
||||
const [sortConfig, setSortConfig] = useState({
|
||||
key: "spend",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
const handleSort = (key) => {
|
||||
setSortConfig((prev) => ({
|
||||
key,
|
||||
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
||||
}));
|
||||
};
|
||||
|
||||
const computeDateRange = (timeframe) => {
|
||||
// Create date in Eastern Time
|
||||
const now = new Date();
|
||||
const easternTime = new Date(
|
||||
now.toLocaleString("en-US", { timeZone: "America/New_York" })
|
||||
);
|
||||
easternTime.setHours(0, 0, 0, 0); // Set to start of day
|
||||
|
||||
let sinceDate, untilDate;
|
||||
|
||||
if (timeframe === "today") {
|
||||
// For today, both dates should be the current date in Eastern Time
|
||||
sinceDate = untilDate = new Date(easternTime);
|
||||
} else {
|
||||
// For other periods, calculate the date range
|
||||
untilDate = new Date(easternTime);
|
||||
untilDate.setDate(untilDate.getDate() - 1); // Yesterday
|
||||
|
||||
sinceDate = new Date(untilDate);
|
||||
sinceDate.setDate(sinceDate.getDate() - parseInt(timeframe) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
since: sinceDate.toISOString().split("T")[0],
|
||||
until: untilDate.toISOString().split("T")[0],
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetaAdsData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { since, until } = computeDateRange(timeframe);
|
||||
|
||||
const [campaignData, accountInsights] = await Promise.all([
|
||||
fetch(`/api/meta/campaigns?since=${since}&until=${until}`),
|
||||
fetch(`/api/meta/account-insights?since=${since}&until=${until}`)
|
||||
]);
|
||||
|
||||
const [campaignsJson, accountJson] = await Promise.all([
|
||||
campaignData.json(),
|
||||
accountInsights.json()
|
||||
]);
|
||||
|
||||
// Process campaigns with the new processing logic
|
||||
const processedCampaigns = campaignsJson.map(processCampaignData);
|
||||
const activeCampaigns = processedCampaigns.filter(c => c.metrics.spend > 0);
|
||||
setCampaigns(activeCampaigns);
|
||||
|
||||
if (activeCampaigns.length > 0) {
|
||||
const totalSpend = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.spend, 0);
|
||||
const totalImpressions = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.impressions, 0);
|
||||
const totalReach = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.reach, 0);
|
||||
const totalPurchases = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.totalPurchases, 0);
|
||||
const totalPurchaseValue = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.purchaseValue, 0);
|
||||
const totalLinkClicks = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.clicks, 0);
|
||||
const totalPostEngagements = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.totalPostEngagements, 0);
|
||||
|
||||
const numCampaigns = activeCampaigns.length;
|
||||
const avgFrequency = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.frequency, 0) / numCampaigns;
|
||||
const avgCpm = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.cpm, 0) / numCampaigns;
|
||||
const avgCtr = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.ctr, 0) / numCampaigns;
|
||||
const avgCpc = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.cpc, 0) / numCampaigns;
|
||||
|
||||
setSummaryMetrics({
|
||||
totalSpend,
|
||||
totalPurchaseValue,
|
||||
totalLinkClicks,
|
||||
totalImpressions,
|
||||
totalReach,
|
||||
totalPurchases,
|
||||
avgFrequency,
|
||||
avgCpm,
|
||||
avgCtr,
|
||||
avgCpc,
|
||||
totalPostEngagements,
|
||||
totalCampaigns: numCampaigns,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Meta Ads fetch error:", err);
|
||||
setError(`Failed to fetch Meta Ads data: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetaAdsData();
|
||||
}, [timeframe]);
|
||||
|
||||
// Sort campaigns
|
||||
const sortedCampaigns = [...campaigns].sort((a, b) => {
|
||||
const direction = sortConfig.direction === "desc" ? -1 : 1;
|
||||
|
||||
switch (sortConfig.key) {
|
||||
case "date":
|
||||
// Add date sorting using campaign ID (Meta IDs are chronological)
|
||||
return direction * (parseInt(b.id) - parseInt(a.id));
|
||||
case "spend":
|
||||
return direction * ((a.metrics.spend || 0) - (b.metrics.spend || 0));
|
||||
case "reach":
|
||||
return direction * ((a.metrics.reach || 0) - (b.metrics.reach || 0));
|
||||
case "impressions":
|
||||
return direction * ((a.metrics.impressions || 0) - (b.metrics.impressions || 0));
|
||||
case "cpm":
|
||||
return direction * ((a.metrics.cpm || 0) - (b.metrics.cpm || 0));
|
||||
case "ctr":
|
||||
return direction * ((a.metrics.ctr || 0) - (b.metrics.ctr || 0));
|
||||
case "results":
|
||||
return direction * ((getActionValue(a, a.objectiveActionType) || 0) - (getActionValue(b, b.objectiveActionType) || 0));
|
||||
case "value":
|
||||
return direction * ((a.metrics.purchaseValue || 0) - (b.metrics.purchaseValue || 0));
|
||||
case "engagements":
|
||||
return direction * ((a.metrics.totalPostEngagements || 0) - (b.metrics.totalPostEngagements || 0));
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Meta Ads Performance
|
||||
</CardTitle>
|
||||
<Select disabled value="7">
|
||||
<SelectTrigger className="w-[130px] bg-white dark:bg-gray-800">
|
||||
<SelectValue placeholder="Select range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<SkeletonMetricCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<SkeletonTable />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20">
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Meta Ads Performance
|
||||
</CardTitle>
|
||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||
<SelectTrigger className="w-[130px] bg-white dark:bg-gray-800">
|
||||
<SelectValue placeholder="Select range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="today">Today</SelectItem>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="14">Last 14 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||
{[
|
||||
{
|
||||
label: "Active Campaigns",
|
||||
value: summaryMetrics?.totalCampaigns,
|
||||
options: { icon: Target, iconColor: "text-purple-500" },
|
||||
},
|
||||
{
|
||||
label: "Total Spend",
|
||||
value: summaryMetrics?.totalSpend,
|
||||
options: { isMonetary: true, decimalPlaces: 0, icon: DollarSign, iconColor: "text-green-500" },
|
||||
},
|
||||
{
|
||||
label: "Total Reach",
|
||||
value: summaryMetrics?.totalReach,
|
||||
options: { icon: Users, iconColor: "text-blue-500" },
|
||||
},
|
||||
{
|
||||
label: "Total Impressions",
|
||||
value: summaryMetrics?.totalImpressions,
|
||||
options: { icon: Eye, iconColor: "text-indigo-500" },
|
||||
},
|
||||
{
|
||||
label: "Avg Frequency",
|
||||
value: summaryMetrics?.avgFrequency,
|
||||
options: { decimalPlaces: 2, icon: Repeat, iconColor: "text-cyan-500" },
|
||||
},
|
||||
{
|
||||
label: "Total Engagements",
|
||||
value: summaryMetrics?.totalPostEngagements,
|
||||
options: { icon: MessageCircle, iconColor: "text-pink-500" },
|
||||
},
|
||||
{
|
||||
label: "Avg CPM",
|
||||
value: summaryMetrics?.avgCpm,
|
||||
options: { isMonetary: true, decimalPlaces: 2, icon: DollarSign, iconColor: "text-emerald-500" },
|
||||
},
|
||||
{
|
||||
label: "Avg CTR",
|
||||
value: summaryMetrics?.avgCtr,
|
||||
options: { isPercentage: true, decimalPlaces: 2, icon: BarChart, iconColor: "text-orange-500" },
|
||||
},
|
||||
{
|
||||
label: "Avg CPC",
|
||||
value: summaryMetrics?.avgCpc,
|
||||
options: { isMonetary: true, decimalPlaces: 2, icon: MousePointer, iconColor: "text-rose-500" },
|
||||
},
|
||||
{
|
||||
label: "Total Link Clicks",
|
||||
value: summaryMetrics?.totalLinkClicks,
|
||||
options: { icon: MousePointer, iconColor: "text-amber-500" },
|
||||
},
|
||||
{
|
||||
label: "Total Purchases",
|
||||
value: summaryMetrics?.totalPurchases,
|
||||
options: { icon: ShoppingCart, iconColor: "text-teal-500" },
|
||||
},
|
||||
{
|
||||
label: "Purchase Value",
|
||||
value: summaryMetrics?.totalPurchaseValue,
|
||||
options: { isMonetary: true, decimalPlaces: 0, icon: DollarSign, iconColor: "text-lime-500" },
|
||||
},
|
||||
].map((card) => (
|
||||
<div key={card.label} className="h-full">
|
||||
{summaryCard(card.label, card.value, card.options)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-800">
|
||||
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="pl-0 justify-start w-full h-8"
|
||||
onClick={() => handleSort("date")}
|
||||
>
|
||||
Campaign
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "spend" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
onClick={() => handleSort("spend")}
|
||||
>
|
||||
Spend
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "reach" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
onClick={() => handleSort("reach")}
|
||||
>
|
||||
Reach
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "impressions" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
onClick={() => handleSort("impressions")}
|
||||
>
|
||||
Impressions
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "cpm" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
onClick={() => handleSort("cpm")}
|
||||
>
|
||||
CPM
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "ctr" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
onClick={() => handleSort("ctr")}
|
||||
>
|
||||
CTR
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "results" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
onClick={() => handleSort("results")}
|
||||
>
|
||||
Results
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "value" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
onClick={() => handleSort("value")}
|
||||
>
|
||||
Value
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
|
||||
<Button
|
||||
variant={sortConfig.key === "engagements" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
onClick={() => handleSort("engagements")}
|
||||
>
|
||||
Engagements
|
||||
</Button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{sortedCampaigns.map((campaign) => (
|
||||
<tr
|
||||
key={campaign.id}
|
||||
className="hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<td className="p-2 align-top">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100 break-words min-w-[200px] max-w-[300px]">
|
||||
<CampaignName name={campaign.name} />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{campaign.objective}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<MetricCell
|
||||
value={campaign.metrics.spend}
|
||||
isMonetary
|
||||
decimalPlaces={2}
|
||||
sublabel={
|
||||
campaign.budget
|
||||
? `${formatCurrency(campaign.budget, 0)}/${campaign.budgetType}`
|
||||
: "Budget: Ad set"
|
||||
}
|
||||
/>
|
||||
|
||||
<MetricCell
|
||||
value={campaign.metrics.reach}
|
||||
label={`${formatNumber(campaign.metrics.frequency, 2)}x freq`}
|
||||
/>
|
||||
|
||||
<MetricCell
|
||||
value={campaign.metrics.impressions}
|
||||
/>
|
||||
|
||||
<MetricCell
|
||||
value={campaign.metrics.cpm}
|
||||
isMonetary
|
||||
decimalPlaces={2}
|
||||
/>
|
||||
|
||||
<MetricCell
|
||||
value={campaign.metrics.ctr}
|
||||
isPercentage
|
||||
decimalPlaces={2}
|
||||
label={`${formatCurrency(campaign.metrics.cpc, 2)} CPC`}
|
||||
/>
|
||||
|
||||
<MetricCell
|
||||
value={getActionValue(campaign, campaign.objectiveActionType)}
|
||||
label={campaign.objective}
|
||||
/>
|
||||
|
||||
<MetricCell
|
||||
value={campaign.metrics.purchaseValue}
|
||||
isMonetary
|
||||
decimalPlaces={2}
|
||||
sublabel={campaign.metrics.costPerResult ? `${formatCurrency(campaign.metrics.costPerResult)}/result` : null}
|
||||
/>
|
||||
|
||||
<MetricCell
|
||||
value={campaign.metrics.totalPostEngagements}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetaCampaigns;
|
||||
487
inventory/src/components/dashboard/MiniEventFeed.jsx
Normal file
487
inventory/src/components/dashboard/MiniEventFeed.jsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/dashboard/ui/card";
|
||||
import { Badge } from "@/components/dashboard/ui/badge";
|
||||
import { ScrollArea } from "@/components/dashboard/ui/scroll-area";
|
||||
import {
|
||||
Package,
|
||||
Truck,
|
||||
UserPlus,
|
||||
XCircle,
|
||||
DollarSign,
|
||||
Activity,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
import { EventDialog } from "./EventFeed.jsx";
|
||||
import { Button } from "@/components/dashboard/ui/button";
|
||||
|
||||
const METRIC_IDS = {
|
||||
PLACED_ORDER: "Y8cqcF",
|
||||
SHIPPED_ORDER: "VExpdL",
|
||||
ACCOUNT_CREATED: "TeeypV",
|
||||
CANCELED_ORDER: "YjVMNg",
|
||||
NEW_BLOG_POST: "YcxeDr",
|
||||
PAYMENT_REFUNDED: "R7XUYh",
|
||||
};
|
||||
|
||||
const EVENT_TYPES = {
|
||||
[METRIC_IDS.PLACED_ORDER]: {
|
||||
label: "Order Placed",
|
||||
color: "bg-green-200",
|
||||
textColor: "text-green-50",
|
||||
iconColor: "text-green-800",
|
||||
gradient: "from-green-800 to-green-700",
|
||||
},
|
||||
[METRIC_IDS.SHIPPED_ORDER]: {
|
||||
label: "Order Shipped",
|
||||
color: "bg-blue-200",
|
||||
textColor: "text-blue-50",
|
||||
iconColor: "text-blue-800",
|
||||
gradient: "from-blue-800 to-blue-700",
|
||||
},
|
||||
[METRIC_IDS.ACCOUNT_CREATED]: {
|
||||
label: "New Account",
|
||||
color: "bg-purple-200",
|
||||
textColor: "text-purple-50",
|
||||
iconColor: "text-purple-800",
|
||||
gradient: "from-purple-800 to-purple-700",
|
||||
},
|
||||
[METRIC_IDS.CANCELED_ORDER]: {
|
||||
label: "Order Canceled",
|
||||
color: "bg-red-200",
|
||||
textColor: "text-red-50",
|
||||
iconColor: "text-red-800",
|
||||
gradient: "from-red-800 to-red-700",
|
||||
},
|
||||
[METRIC_IDS.PAYMENT_REFUNDED]: {
|
||||
label: "Payment Refunded",
|
||||
color: "bg-orange-200",
|
||||
textColor: "text-orange-50",
|
||||
iconColor: "text-orange-800",
|
||||
gradient: "from-orange-800 to-orange-700",
|
||||
},
|
||||
[METRIC_IDS.NEW_BLOG_POST]: {
|
||||
label: "New Blog Post",
|
||||
color: "bg-indigo-200",
|
||||
textColor: "text-indigo-50",
|
||||
iconColor: "text-indigo-800",
|
||||
gradient: "from-indigo-800 to-indigo-700",
|
||||
},
|
||||
};
|
||||
|
||||
const EVENT_ICONS = {
|
||||
[METRIC_IDS.PLACED_ORDER]: Package,
|
||||
[METRIC_IDS.SHIPPED_ORDER]: Truck,
|
||||
[METRIC_IDS.ACCOUNT_CREATED]: UserPlus,
|
||||
[METRIC_IDS.CANCELED_ORDER]: XCircle,
|
||||
[METRIC_IDS.PAYMENT_REFUNDED]: DollarSign,
|
||||
[METRIC_IDS.NEW_BLOG_POST]: FileText,
|
||||
};
|
||||
|
||||
// Loading State Component
|
||||
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">
|
||||
<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" />
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-1">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-36 bg-gray-700" />
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-28 bg-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Empty State Component
|
||||
const EmptyState = () => (
|
||||
<Card className="w-[210px] h-[125px] bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-sm">
|
||||
<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>
|
||||
<p className="text-sm text-gray-400 font-medium">
|
||||
No recent activity
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const EventCard = ({ event }) => {
|
||||
const eventType = EVENT_TYPES[event.metric_id];
|
||||
if (!eventType) return null;
|
||||
|
||||
const Icon = EVENT_ICONS[event.metric_id] || Package;
|
||||
const details = event.event_properties || {};
|
||||
|
||||
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`}>
|
||||
<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}`}>
|
||||
{eventType.label}
|
||||
</CardTitle>
|
||||
{event.datetime && (
|
||||
<CardDescription className={`text-xs ${eventType.textColor} opacity-80`}>
|
||||
{format(new Date(event.datetime), "h:mm a")}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative p-2">
|
||||
<div className={`absolute inset-0 rounded-full ${eventType.color}`} />
|
||||
<Icon className={`h-4 w-4 ${eventType.iconColor} relative`} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-1">
|
||||
{event.metric_id === METRIC_IDS.PLACED_ORDER && (
|
||||
<>
|
||||
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
|
||||
{details.ShippingName}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate`}>
|
||||
#{details.OrderId} • {formatCurrency(details.TotalAmount)}
|
||||
</div>
|
||||
</div>
|
||||
{(details.IsOnHold || details.OnHoldReleased || details.StillOwes || details.LocalPickup || details.HasPreorder || details.HasNotions || details.OnlyDigitalGC || details.HasDigitalGC || details.HasDigiItem || details.OnlyDigiItem) && (
|
||||
<div className="flex gap-1.5 items-center flex-wrap mt-1">
|
||||
{details.IsOnHold && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-xs py-0"
|
||||
>
|
||||
On Hold
|
||||
</Badge>
|
||||
)}
|
||||
{details.OnHoldReleased && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs py-0"
|
||||
>
|
||||
Hold Released
|
||||
</Badge>
|
||||
)}
|
||||
{details.StillOwes && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs py-0"
|
||||
>
|
||||
Owes
|
||||
</Badge>
|
||||
)}
|
||||
{details.LocalPickup && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs py-0"
|
||||
>
|
||||
Local
|
||||
</Badge>
|
||||
)}
|
||||
{details.HasPreorder && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-purple-100 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 text-xs py-0"
|
||||
>
|
||||
Pre-order
|
||||
</Badge>
|
||||
)}
|
||||
{details.HasNotions && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300 text-xs py-0"
|
||||
>
|
||||
Notions
|
||||
</Badge>
|
||||
)}
|
||||
{(details.OnlyDigitalGC || details.HasDigitalGC) && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-pink-100 dark:bg-pink-900/20 text-pink-700 dark:text-pink-300 text-xs py-0"
|
||||
>
|
||||
eGift Card
|
||||
</Badge>
|
||||
)}
|
||||
{(details.HasDigiItem || details.OnlyDigiItem) && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-indigo-100 dark:bg-indigo-900/20 text-indigo-700 dark:text-indigo-300 text-xs py-0"
|
||||
>
|
||||
Digital
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{event.metric_id === METRIC_IDS.SHIPPED_ORDER && (
|
||||
<>
|
||||
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
|
||||
{details.ShippingName}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate`}>
|
||||
#{details.OrderId} • {formatShipMethodSimple(details.ShipMethod)}
|
||||
</div>
|
||||
</div>
|
||||
{event.event_properties?.ShippedBy && (
|
||||
<div className={`text-sm font-medium ${eventType.textColor} opacity-90 truncate mt-1`}>
|
||||
Shipped by {event.event_properties.ShippedBy}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{event.metric_id === METRIC_IDS.ACCOUNT_CREATED && (
|
||||
<>
|
||||
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
|
||||
{details.FirstName} {details.LastName}
|
||||
</div>
|
||||
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate mt-1`}>
|
||||
{details.EmailAddress}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{event.metric_id === METRIC_IDS.CANCELED_ORDER && (
|
||||
<>
|
||||
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
|
||||
{details.ShippingName}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate`}>
|
||||
#{details.OrderId} • {formatCurrency(details.TotalAmount)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-xs ${eventType.textColor} opacity-80 mt-1.5 truncate`}>
|
||||
{details.CancelReason}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && (
|
||||
<>
|
||||
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
|
||||
{details.ShippingName}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate`}>
|
||||
#{details.FromOrder} • {formatCurrency(details.PaymentAmount)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-xs ${eventType.textColor} opacity-80 mt-1.5 truncate`}>
|
||||
via {details.PaymentName}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{event.metric_id === METRIC_IDS.NEW_BLOG_POST && (
|
||||
<>
|
||||
<div className={`text-lg truncate font-bold ${eventType.textColor}`}>
|
||||
{details.title}
|
||||
</div>
|
||||
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 line-clamp-2 mt-1`}>
|
||||
{details.description}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</EventDialog>
|
||||
);
|
||||
};
|
||||
|
||||
const DEFAULT_METRICS = Object.values(METRIC_IDS);
|
||||
|
||||
const MiniEventFeed = ({
|
||||
selectedMetrics = DEFAULT_METRICS,
|
||||
}) => {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [lastUpdate, setLastUpdate] = useState(null);
|
||||
const scrollRef = useRef(null);
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(false);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (scrollRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeftArrow(scrollLeft > 0);
|
||||
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToEnd = () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTo({
|
||||
left: scrollRef.current.scrollWidth,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToStart = () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTo({
|
||||
left: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
if (events.length === 0) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const response = await axios.get("/api/klaviyo/events/feed", {
|
||||
params: {
|
||||
timeRange: "today",
|
||||
metricIds: JSON.stringify(selectedMetrics),
|
||||
},
|
||||
});
|
||||
|
||||
const processedEvents = (response.data.data || []).map((event) => ({
|
||||
...event,
|
||||
datetime: event.attributes?.datetime || event.datetime,
|
||||
event_properties: event.attributes?.event_properties || {}
|
||||
}));
|
||||
|
||||
setEvents(processedEvents);
|
||||
setLastUpdate(new Date());
|
||||
|
||||
// Scroll to the right after events are loaded
|
||||
if (scrollRef.current) {
|
||||
setTimeout(() => {
|
||||
scrollRef.current.scrollTo({
|
||||
left: scrollRef.current.scrollWidth,
|
||||
behavior: 'instant'
|
||||
});
|
||||
handleScroll();
|
||||
}, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching events:", error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
const interval = setInterval(fetchEvents, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0">
|
||||
<Card className="bg-gradient-to-br rounded-none from-gray-900 to-gray-600 backdrop-blur">
|
||||
<div className="px-1 pt-2 pb-3 relative">
|
||||
{showLeftArrow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-gray-900/50 hover:bg-gray-900/75 h-12 w-8 p-0 [&_svg]:!h-8 [&_svg]:!w-8"
|
||||
onClick={scrollToStart}
|
||||
>
|
||||
<ChevronLeft className="text-white" />
|
||||
</Button>
|
||||
)}
|
||||
{showRightArrow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-gray-900/50 hover:bg-gray-900/75 h-12 w-8 p-0 [&_svg]:!h-8 [&_svg]:!w-8"
|
||||
onClick={scrollToEnd}
|
||||
>
|
||||
<ChevronRight className="text-white" />
|
||||
</Button>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']"
|
||||
>
|
||||
<div className="flex flex-row gap-3 pr-4" style={{ width: 'max-content' }}>
|
||||
{loading && !events.length ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<Alert variant="destructive" className="mx-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Failed to load event feed: {error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : !events || events.length === 0 ? (
|
||||
<div className="px-4">
|
||||
<EmptyState />
|
||||
</div>
|
||||
) : (
|
||||
[...events].reverse().map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniEventFeed;
|
||||
|
||||
// Helper Functions
|
||||
const formatCurrency = (amount) => {
|
||||
// Convert to number if it's a string
|
||||
const num = typeof amount === "string" ? parseFloat(amount) : amount;
|
||||
// Handle negative numbers
|
||||
const absNum = Math.abs(num);
|
||||
// Format to 2 decimal places and add negative sign if needed
|
||||
return `${num < 0 ? "-" : ""}$${absNum.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatShipMethodSimple = (method) => {
|
||||
if (!method) return "Digital";
|
||||
if (method.includes("usps")) return "USPS";
|
||||
if (method.includes("fedex")) return "FedEx";
|
||||
if (method.includes("ups")) return "UPS";
|
||||
return "Standard";
|
||||
};
|
||||
256
inventory/src/components/dashboard/MiniRealtimeAnalytics.jsx
Normal file
256
inventory/src/components/dashboard/MiniRealtimeAnalytics.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { AlertTriangle, Users, Activity } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/dashboard/ui/alert";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
summaryCard,
|
||||
SkeletonSummaryCard,
|
||||
SkeletonBarChart,
|
||||
processBasicData,
|
||||
} from "./RealtimeAnalytics";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
|
||||
const SkeletonCard = ({ colorScheme = "sky" }) => (
|
||||
<Card className={`w-full h-[150px] bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
|
||||
</div>
|
||||
</CardTitle>
|
||||
<div className="relative p-2">
|
||||
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300/20`} />
|
||||
<div className="h-5 w-5 relative rounded-full bg-${colorScheme}-300/20" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className={`h-8 w-32 bg-${colorScheme}-300/20`} />
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const MiniRealtimeAnalytics = () => {
|
||||
const [basicData, setBasicData] = useState({
|
||||
last30MinUsers: 0,
|
||||
last5MinUsers: 0,
|
||||
byMinute: [],
|
||||
tokenQuota: null,
|
||||
lastUpdated: null,
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let basicInterval;
|
||||
|
||||
const fetchBasicData = async () => {
|
||||
if (isPaused) return;
|
||||
try {
|
||||
const response = await fetch("/api/dashboard-analytics/realtime/basic", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch basic realtime data");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const processed = processBasicData(result.data);
|
||||
setBasicData(processed);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Error details:", {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
response: error.response,
|
||||
});
|
||||
if (error.message === "QUOTA_EXCEEDED") {
|
||||
setError("Quota exceeded. Analytics paused until manually resumed.");
|
||||
setIsPaused(true);
|
||||
} else {
|
||||
setError("Failed to fetch analytics data");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchBasicData();
|
||||
|
||||
// Set up interval
|
||||
basicInterval = setInterval(fetchBasicData, 30000); // 30 seconds
|
||||
|
||||
return () => {
|
||||
clearInterval(basicInterval);
|
||||
};
|
||||
}, [isPaused]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
||||
<SkeletonCard colorScheme="sky" />
|
||||
<SkeletonCard colorScheme="sky" />
|
||||
</div>
|
||||
|
||||
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[216px]">
|
||||
<div className="h-full w-full relative">
|
||||
{/* Grid lines */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-full h-px bg-sky-300/20"
|
||||
style={{ top: `${(i + 1) * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-6 bg-sky-300/20 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* X-axis labels */}
|
||||
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-8 bg-sky-300/20 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* Bars */}
|
||||
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between gap-1">
|
||||
{[...Array(24)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-2 bg-sky-300/20 rounded-sm"
|
||||
style={{ height: `${Math.random() * 80 + 10}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
||||
{summaryCard(
|
||||
"Last 30 Minutes",
|
||||
"Active users",
|
||||
basicData.last30MinUsers,
|
||||
{
|
||||
colorClass: "text-sky-200",
|
||||
titleClass: "text-sky-100 font-bold text-md",
|
||||
descriptionClass: "pt-2 text-sky-200 text-md font-semibold",
|
||||
background: "h-[150px] pt-2 bg-gradient-to-br from-sky-900 to-sky-800",
|
||||
icon: Users,
|
||||
iconColor: "text-sky-900",
|
||||
iconBackground: "bg-sky-300"
|
||||
}
|
||||
)}
|
||||
{summaryCard(
|
||||
"Last 5 Minutes",
|
||||
"Active users",
|
||||
basicData.last5MinUsers,
|
||||
{
|
||||
colorClass: "text-sky-200",
|
||||
titleClass: "text-sky-100 font-bold text-md",
|
||||
descriptionClass: "pt-2 text-sky-200 text-md font-semibold",
|
||||
background: "h-[150px] pt-2 bg-gradient-to-br from-sky-900 to-sky-800",
|
||||
icon: Activity,
|
||||
iconColor: "text-sky-900",
|
||||
iconBackground: "bg-sky-300"
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[216px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={basicData.byMinute}
|
||||
margin={{ top: 5, right: 5, left: -35, bottom: -10 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="minute"
|
||||
tickFormatter={(value) => value + "m"}
|
||||
className="text-xs"
|
||||
tick={{ fill: "#e0f2fe" }}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs"
|
||||
tick={{ fill: "#e0f2fe" }}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<Card className="p-2 shadow-lg bg-sky-800 border-none">
|
||||
<CardContent className="p-0 space-y-1">
|
||||
<p className="font-medium text-sm text-sky-100 border-b border-sky-700 pb-1 mb-1">
|
||||
{payload[0].payload.timestamp}
|
||||
</p>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-sky-200">
|
||||
Active Users:
|
||||
</span>
|
||||
<span className="font-medium ml-4 text-sky-100">
|
||||
{payload[0].value}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="users" fill="#0EA5E9" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return renderContent();
|
||||
};
|
||||
|
||||
export default MiniRealtimeAnalytics;
|
||||
487
inventory/src/components/dashboard/MiniSalesChart.jsx
Normal file
487
inventory/src/components/dashboard/MiniSalesChart.jsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import React, { useState, useEffect, useCallback, memo } from "react";
|
||||
import axios from "axios";
|
||||
import { acotService } from "@/services/dashboard/acotService";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/dashboard/ui/card";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { DateTime } from "luxon";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
|
||||
import { AlertCircle, TrendingUp, DollarSign, ShoppingCart, Truck, PiggyBank, ArrowUp,ArrowDown, Banknote, Package } from "lucide-react";
|
||||
import { formatCurrency, CustomTooltip, processData, StatCard } from "./SalesChart.jsx";
|
||||
|
||||
const SkeletonChart = () => (
|
||||
<div className="h-[216px]">
|
||||
<div className="h-full w-full relative">
|
||||
{/* Grid lines */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-full h-px bg-slate-600"
|
||||
style={{ top: `${(i + 1) * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-6 bg-slate-600 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* X-axis labels */}
|
||||
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-8 bg-slate-600 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* Chart lines */}
|
||||
<div className="absolute inset-x-8 bottom-6 top-4">
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-600 rounded-sm"
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MiniStatCard = memo(({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
colorClass,
|
||||
iconColor,
|
||||
iconBackground,
|
||||
background,
|
||||
previousValue,
|
||||
trend,
|
||||
trendValue,
|
||||
onClick,
|
||||
active = true,
|
||||
titleClass = "text-sm font-bold text-gray-100",
|
||||
descriptionClass = "text-sm font-semibold text-gray-200"
|
||||
}) => (
|
||||
<Card
|
||||
className={`w-full bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm ${
|
||||
onClick ? 'cursor-pointer transition-all hover:brightness-110' : ''
|
||||
} ${!active ? 'opacity-50' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className={titleClass}>
|
||||
{title}
|
||||
</CardTitle>
|
||||
{Icon && (
|
||||
<div className="relative p-2">
|
||||
<div className={`absolute inset-0 rounded-full ${iconBackground}`} />
|
||||
<Icon className={`h-5 w-5 ${iconColor} relative`} />
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className={`text-3xl font-extrabold ${colorClass}`}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-2 items-center justify-between flex">
|
||||
<span className={descriptionClass}>Prev: {previousValue}</span>
|
||||
{trend && (
|
||||
<span
|
||||
className={`flex items-center gap-0 px-1 py-0.5 rounded-full ${
|
||||
trend === 'up'
|
||||
? 'text-sm font-bold bg-emerald-300 text-emerald-900'
|
||||
: 'text-sm font-bold bg-rose-300 text-rose-900'
|
||||
}`}
|
||||
>
|
||||
{trend === "up" ? (
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
)}
|
||||
{trendValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
));
|
||||
|
||||
MiniStatCard.displayName = "MiniStatCard";
|
||||
|
||||
const SkeletonCard = ({ colorScheme = "emerald" }) => (
|
||||
<Card className="w-full h-[150px] bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
|
||||
</div>
|
||||
</CardTitle>
|
||||
<div className="relative p-2">
|
||||
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300`} />
|
||||
<Skeleton className={`h-5 w-5 bg-${colorScheme}-300 relative rounded-full`} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className={`h-8 w-20 bg-${colorScheme}-300`} />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
|
||||
<Skeleton className={`h-4 w-12 bg-${colorScheme}-300 rounded-full`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const MiniSalesChart = ({ className = "" }) => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [visibleMetrics, setVisibleMetrics] = useState({
|
||||
revenue: true,
|
||||
orders: true
|
||||
});
|
||||
const [summaryStats, setSummaryStats] = useState({
|
||||
totalRevenue: 0,
|
||||
totalOrders: 0,
|
||||
prevRevenue: 0,
|
||||
prevOrders: 0,
|
||||
periodProgress: 100
|
||||
});
|
||||
const [projection, setProjection] = useState(null);
|
||||
const [projectionLoading, setProjectionLoading] = useState(false);
|
||||
|
||||
const fetchProjection = useCallback(async () => {
|
||||
if (summaryStats.periodProgress >= 100) return;
|
||||
|
||||
try {
|
||||
setProjectionLoading(true);
|
||||
const response = await acotService.getProjection({ timeRange: "last30days" });
|
||||
setProjection(response);
|
||||
} catch (error) {
|
||||
console.error("Error loading projection:", error);
|
||||
} finally {
|
||||
setProjectionLoading(false);
|
||||
}
|
||||
}, [summaryStats.periodProgress]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await acotService.getStatsDetails({
|
||||
timeRange: "last30days",
|
||||
metric: "revenue",
|
||||
daily: true,
|
||||
});
|
||||
|
||||
if (!response.stats) {
|
||||
throw new Error("Invalid response format");
|
||||
}
|
||||
|
||||
const stats = Array.isArray(response.stats)
|
||||
? response.stats
|
||||
: [];
|
||||
|
||||
const processedData = processData(stats);
|
||||
|
||||
// Calculate totals and growth
|
||||
const totals = stats.reduce((acc, day) => ({
|
||||
totalRevenue: acc.totalRevenue + (Number(day.revenue) || 0),
|
||||
totalOrders: acc.totalOrders + (Number(day.orders) || 0),
|
||||
prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0),
|
||||
prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0),
|
||||
periodProgress: day.periodProgress || 100,
|
||||
}), {
|
||||
totalRevenue: 0,
|
||||
totalOrders: 0,
|
||||
prevRevenue: 0,
|
||||
prevOrders: 0,
|
||||
periodProgress: 100
|
||||
});
|
||||
|
||||
setData(processedData);
|
||||
setSummaryStats(totals);
|
||||
setError(null);
|
||||
|
||||
// Fetch projection if needed
|
||||
if (totals.periodProgress < 100) {
|
||||
fetchProjection();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchProjection]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const intervalId = setInterval(fetchData, 300000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [fetchData]);
|
||||
|
||||
const formatXAxis = (value) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric"
|
||||
});
|
||||
};
|
||||
|
||||
const toggleMetric = (metric) => {
|
||||
setVisibleMetrics(prev => ({
|
||||
...prev,
|
||||
[metric]: !prev[metric]
|
||||
}));
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive" className="bg-white/10 backdrop-blur-sm">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>Failed to load sales data: {error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SkeletonCard colorScheme="emerald" />
|
||||
<SkeletonCard colorScheme="blue" />
|
||||
</div>
|
||||
|
||||
{/* Chart Card */}
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<SkeletonChart />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{loading ? (
|
||||
<>
|
||||
<SkeletonCard colorScheme="emerald" />
|
||||
<SkeletonCard colorScheme="blue" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MiniStatCard
|
||||
title="30 Days Revenue"
|
||||
value={formatCurrency(summaryStats.totalRevenue, false)}
|
||||
previousValue={formatCurrency(summaryStats.prevRevenue, false)}
|
||||
trend={
|
||||
summaryStats.periodProgress < 100
|
||||
? ((projection?.projectedRevenue || summaryStats.totalRevenue) >= summaryStats.prevRevenue ? "up" : "down")
|
||||
: (summaryStats.totalRevenue >= summaryStats.prevRevenue ? "up" : "down")
|
||||
}
|
||||
trendValue={
|
||||
summaryStats.periodProgress < 100
|
||||
? `${Math.abs(Math.round(((projection?.projectedRevenue || summaryStats.totalRevenue) - summaryStats.prevRevenue) / summaryStats.prevRevenue * 100))}%`
|
||||
: `${Math.abs(Math.round(((summaryStats.totalRevenue - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100))}%`
|
||||
}
|
||||
colorClass="text-emerald-300"
|
||||
titleClass="text-emerald-300 font-bold text-md"
|
||||
descriptionClass="text-emerald-300 text-md font-semibold pb-1"
|
||||
icon={PiggyBank}
|
||||
iconColor="text-emerald-900"
|
||||
iconBackground="bg-emerald-300"
|
||||
onClick={() => toggleMetric('revenue')}
|
||||
active={visibleMetrics.revenue}
|
||||
/>
|
||||
<MiniStatCard
|
||||
title="30 Days Orders"
|
||||
value={summaryStats.totalOrders.toLocaleString()}
|
||||
previousValue={summaryStats.prevOrders.toLocaleString()}
|
||||
trend={
|
||||
summaryStats.periodProgress < 100
|
||||
? ((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) >= summaryStats.prevOrders ? "up" : "down")
|
||||
: (summaryStats.totalOrders >= summaryStats.prevOrders ? "up" : "down")
|
||||
}
|
||||
trendValue={
|
||||
summaryStats.periodProgress < 100
|
||||
? `${Math.abs(Math.round(((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) - summaryStats.prevOrders) / summaryStats.prevOrders * 100))}%`
|
||||
: `${Math.abs(Math.round(((summaryStats.totalOrders - summaryStats.prevOrders) / summaryStats.prevOrders) * 100))}%`
|
||||
}
|
||||
colorClass="text-blue-300"
|
||||
titleClass="text-blue-300 font-bold text-md"
|
||||
descriptionClass="text-blue-300 text-md font-semibold pb-1"
|
||||
icon={Truck}
|
||||
iconColor="text-blue-900"
|
||||
iconBackground="bg-blue-300"
|
||||
onClick={() => toggleMetric('orders')}
|
||||
active={visibleMetrics.orders}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart Card */}
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[216px]">
|
||||
{loading ? (
|
||||
<div className="h-full w-full relative">
|
||||
{/* Grid lines */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-full h-px bg-slate-600"
|
||||
style={{ top: `${(i + 1) * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-6 bg-slate-600 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* X-axis labels */}
|
||||
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-8 bg-slate-600 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* Chart lines */}
|
||||
<div className="absolute inset-x-8 bottom-6 top-4">
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-600 rounded-sm"
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{ top: 0, right: -30, left: -5, bottom: -10 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-stone-700" />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickFormatter={formatXAxis}
|
||||
className="text-xs"
|
||||
tick={{ fill: "#f5f5f4" }}
|
||||
/>
|
||||
{visibleMetrics.revenue && (
|
||||
<YAxis
|
||||
yAxisId="revenue"
|
||||
tickFormatter={(value) => formatCurrency(value, false)}
|
||||
className="text-xs"
|
||||
tick={{ fill: "#f5f5f4" }}
|
||||
/>
|
||||
)}
|
||||
{visibleMetrics.orders && (
|
||||
<YAxis
|
||||
yAxisId="orders"
|
||||
orientation="right"
|
||||
className="text-xs"
|
||||
tick={{ fill: "#f5f5f4" }}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const timestamp = new Date(payload[0].payload.timestamp);
|
||||
return (
|
||||
<Card className="p-2 shadow-lg bg-stone-800 border-none">
|
||||
<CardContent className="p-0 space-y-1">
|
||||
<p className="font-medium text-sm text-stone-100 border-b border-stone-700 pb-1 mb-1">
|
||||
{timestamp.toLocaleDateString([], {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric"
|
||||
})}
|
||||
</p>
|
||||
{payload
|
||||
.filter(entry => visibleMetrics[entry.dataKey])
|
||||
.map((entry, index) => (
|
||||
<div key={index} className="flex justify-between items-center text-sm">
|
||||
<span className="text-stone-200">
|
||||
{entry.name}:
|
||||
</span>
|
||||
<span className="font-medium ml-4 text-stone-100">
|
||||
{entry.dataKey === 'revenue'
|
||||
? formatCurrency(entry.value)
|
||||
: entry.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
{visibleMetrics.revenue && (
|
||||
<Line
|
||||
yAxisId="revenue"
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
{visibleMetrics.orders && (
|
||||
<Line
|
||||
yAxisId="orders"
|
||||
type="monotone"
|
||||
dataKey="orders"
|
||||
name="Orders"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniSalesChart;
|
||||
747
inventory/src/components/dashboard/MiniStatCards.jsx
Normal file
747
inventory/src/components/dashboard/MiniStatCards.jsx
Normal file
@@ -0,0 +1,747 @@
|
||||
import React, { useState, useEffect, useCallback, memo } from "react";
|
||||
import axios from "axios";
|
||||
import { acotService } from "@/services/dashboard/acotService";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/dashboard/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/dashboard/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dashboard/ui/dialog";
|
||||
import { DateTime } from "luxon";
|
||||
import { TIME_RANGES } from "@/lib/dashboard/constants";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
|
||||
import {
|
||||
DollarSign,
|
||||
ShoppingCart,
|
||||
Package,
|
||||
AlertCircle,
|
||||
CircleDollarSign,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TooltipProvider,
|
||||
} from "@/components/dashboard/ui/tooltip";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/dashboard/ui/table";
|
||||
|
||||
// Import the detail view components and utilities from StatCards
|
||||
import {
|
||||
RevenueDetails,
|
||||
OrdersDetails,
|
||||
AverageOrderDetails,
|
||||
ShippingDetails,
|
||||
StatCard,
|
||||
DetailDialog,
|
||||
formatCurrency,
|
||||
formatPercentage,
|
||||
SkeletonCard,
|
||||
} from "./StatCards";
|
||||
|
||||
// Mini skeleton components
|
||||
const MiniSkeletonChart = ({ type = "line" }) => (
|
||||
<div className={`h-[230px] w-full ${
|
||||
type === 'revenue' ? 'bg-emerald-50/10' :
|
||||
type === 'orders' ? 'bg-blue-50/10' :
|
||||
type === 'average_order' ? 'bg-violet-50/10' :
|
||||
'bg-orange-50/10'
|
||||
} rounded-lg p-4`}>
|
||||
<div className="h-full relative">
|
||||
{/* Grid lines */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute w-full h-px ${
|
||||
type === 'revenue' ? 'bg-emerald-200/20' :
|
||||
type === 'orders' ? 'bg-blue-200/20' :
|
||||
type === 'average_order' ? 'bg-violet-200/20' :
|
||||
'bg-orange-200/20'
|
||||
}`}
|
||||
style={{ top: `${(i + 1) * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className={`h-3 w-6 ${
|
||||
type === 'revenue' ? 'bg-emerald-200/20' :
|
||||
type === 'orders' ? 'bg-blue-200/20' :
|
||||
type === 'average_order' ? 'bg-violet-200/20' :
|
||||
'bg-orange-200/20'
|
||||
} rounded-sm`} />
|
||||
))}
|
||||
</div>
|
||||
{/* X-axis labels */}
|
||||
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className={`h-3 w-8 ${
|
||||
type === 'revenue' ? 'bg-emerald-200/20' :
|
||||
type === 'orders' ? 'bg-blue-200/20' :
|
||||
type === 'average_order' ? 'bg-violet-200/20' :
|
||||
'bg-orange-200/20'
|
||||
} rounded-sm`} />
|
||||
))}
|
||||
</div>
|
||||
{type === "bar" ? (
|
||||
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between gap-1">
|
||||
{[...Array(24)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-2 ${
|
||||
type === 'revenue' ? 'bg-emerald-200/20' :
|
||||
type === 'orders' ? 'bg-blue-200/20' :
|
||||
type === 'average_order' ? 'bg-violet-200/20' :
|
||||
'bg-orange-200/20'
|
||||
} rounded-sm`}
|
||||
style={{ height: `${Math.random() * 80 + 10}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-x-8 bottom-6 top-4">
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
type === 'revenue' ? 'bg-emerald-200/20' :
|
||||
type === 'orders' ? 'bg-blue-200/20' :
|
||||
type === 'average_order' ? 'bg-violet-200/20' :
|
||||
'bg-orange-200/20'
|
||||
} rounded-sm`}
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MiniSkeletonTable = ({ rows = 8, colorScheme = "orange" }) => (
|
||||
<div className={`rounded-lg border ${
|
||||
colorScheme === 'orange' ? 'bg-orange-50/10 border-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-50/10 border-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-50/10 border-blue-200/20' :
|
||||
'bg-violet-50/10 border-violet-200/20'
|
||||
}`}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Skeleton className={`h-4 w-32 ${
|
||||
colorScheme === 'orange' ? 'bg-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-200/20' :
|
||||
'bg-violet-200/20'
|
||||
} rounded-sm`} />
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<Skeleton className={`h-4 w-24 ml-auto ${
|
||||
colorScheme === 'orange' ? 'bg-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-200/20' :
|
||||
'bg-violet-200/20'
|
||||
} rounded-sm`} />
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<Skeleton className={`h-4 w-24 ml-auto ${
|
||||
colorScheme === 'orange' ? 'bg-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-200/20' :
|
||||
'bg-violet-200/20'
|
||||
} rounded-sm`} />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(rows)].map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className={`h-4 w-48 ${
|
||||
colorScheme === 'orange' ? 'bg-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-200/20' :
|
||||
'bg-violet-200/20'
|
||||
} rounded-sm`} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className={`h-4 w-16 ml-auto ${
|
||||
colorScheme === 'orange' ? 'bg-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-200/20' :
|
||||
'bg-violet-200/20'
|
||||
} rounded-sm`} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className={`h-4 w-16 ml-auto ${
|
||||
colorScheme === 'orange' ? 'bg-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-200/20' :
|
||||
'bg-violet-200/20'
|
||||
} rounded-sm`} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MiniStatCards = ({
|
||||
timeRange: initialTimeRange = "today",
|
||||
startDate,
|
||||
endDate,
|
||||
title = "Quick Stats",
|
||||
description = "",
|
||||
compact = false,
|
||||
}) => {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [lastUpdate, setLastUpdate] = useState(null);
|
||||
const [timeRange, setTimeRange] = useState(initialTimeRange);
|
||||
const [selectedMetric, setSelectedMetric] = useState(null);
|
||||
const [detailDataLoading, setDetailDataLoading] = useState({});
|
||||
const [detailData, setDetailData] = useState({});
|
||||
const [projection, setProjection] = useState(null);
|
||||
const [projectionLoading, setProjectionLoading] = useState(false);
|
||||
|
||||
// Reuse the trend calculation functions
|
||||
const calculateTrend = useCallback((current, previous) => {
|
||||
if (!current || !previous) return null;
|
||||
const trend = current >= previous ? "up" : "down";
|
||||
const diff = Math.abs(current - previous);
|
||||
const percentage = (diff / previous) * 100;
|
||||
|
||||
return {
|
||||
trend,
|
||||
value: percentage,
|
||||
current,
|
||||
previous,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const calculateRevenueTrend = useCallback(() => {
|
||||
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null;
|
||||
|
||||
// If period is complete, use actual revenue
|
||||
// If period is incomplete, use smart projection when available, fallback to simple projection
|
||||
const currentRevenue = stats.periodProgress < 100
|
||||
? (projection?.projectedRevenue || stats.projectedRevenue)
|
||||
: stats.revenue;
|
||||
const prevRevenue = stats.prevPeriodRevenue; // Previous period's total revenue
|
||||
|
||||
console.log('[MiniStatCards RevenueTrend Debug]', {
|
||||
periodProgress: stats.periodProgress,
|
||||
currentRevenue,
|
||||
smartProjection: projection?.projectedRevenue,
|
||||
simpleProjection: stats.projectedRevenue,
|
||||
actualRevenue: stats.revenue,
|
||||
prevRevenue,
|
||||
isProjected: stats.periodProgress < 100
|
||||
});
|
||||
|
||||
if (!currentRevenue || !prevRevenue) return null;
|
||||
|
||||
// Calculate absolute difference percentage
|
||||
const trend = currentRevenue >= prevRevenue ? "up" : "down";
|
||||
const diff = Math.abs(currentRevenue - prevRevenue);
|
||||
const percentage = (diff / prevRevenue) * 100;
|
||||
|
||||
console.log('[MiniStatCards RevenueTrend Result]', {
|
||||
trend,
|
||||
percentage,
|
||||
calculation: `(|${currentRevenue} - ${prevRevenue}| / ${prevRevenue}) * 100 = ${percentage}%`
|
||||
});
|
||||
|
||||
return {
|
||||
trend,
|
||||
value: percentage,
|
||||
current: currentRevenue,
|
||||
previous: prevRevenue,
|
||||
};
|
||||
}, [stats, projection]);
|
||||
|
||||
const calculateOrderTrend = useCallback(() => {
|
||||
if (!stats?.prevPeriodOrders) return null;
|
||||
return calculateTrend(stats.orderCount, stats.prevPeriodOrders);
|
||||
}, [stats, calculateTrend]);
|
||||
|
||||
const calculateAOVTrend = useCallback(() => {
|
||||
if (!stats?.prevPeriodAOV) return null;
|
||||
return calculateTrend(stats.averageOrderValue, stats.prevPeriodAOV);
|
||||
}, [stats, calculateTrend]);
|
||||
|
||||
// Initial load effect
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStats(null);
|
||||
|
||||
const params =
|
||||
timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
||||
const response = await acotService.getStats(params);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setStats(response.stats);
|
||||
setLastUpdate(DateTime.now().setZone("America/New_York"));
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error loading data:", error);
|
||||
if (isMounted) {
|
||||
setError(error.message);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [timeRange, startDate, endDate]);
|
||||
|
||||
// Load smart projection separately
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadProjection = async () => {
|
||||
if (!stats?.periodProgress || stats.periodProgress >= 100) return;
|
||||
|
||||
try {
|
||||
setProjectionLoading(true);
|
||||
const params =
|
||||
timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
||||
const response = await acotService.getProjection(params);
|
||||
|
||||
if (!isMounted) return;
|
||||
setProjection(response);
|
||||
} catch (error) {
|
||||
console.error("Error loading projection:", error);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setProjectionLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProjection();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [timeRange, startDate, endDate, stats?.periodProgress]);
|
||||
|
||||
// Auto-refresh for 'today' view
|
||||
useEffect(() => {
|
||||
if (timeRange !== "today") return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const [statsResponse, projectionResponse] = await Promise.all([
|
||||
acotService.getStats({ timeRange: "today" }),
|
||||
acotService.getProjection({ timeRange: "today" }),
|
||||
]);
|
||||
|
||||
setStats(statsResponse.stats);
|
||||
setProjection(projectionResponse);
|
||||
setLastUpdate(DateTime.now().setZone("America/New_York"));
|
||||
} catch (error) {
|
||||
console.error("Error auto-refreshing stats:", error);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [timeRange]);
|
||||
|
||||
// Add function to fetch detail data
|
||||
const fetchDetailData = useCallback(
|
||||
async (metric) => {
|
||||
if (detailData[metric]) return;
|
||||
|
||||
setDetailDataLoading((prev) => ({ ...prev, [metric]: true }));
|
||||
try {
|
||||
const response = await acotService.getStatsDetails({
|
||||
timeRange: "last30days",
|
||||
metric,
|
||||
daily: true,
|
||||
});
|
||||
|
||||
setDetailData((prev) => ({ ...prev, [metric]: response.stats }));
|
||||
} catch (error) {
|
||||
console.error(`Error fetching detail data for ${metric}:`, error);
|
||||
} finally {
|
||||
setDetailDataLoading((prev) => ({ ...prev, [metric]: false }));
|
||||
}
|
||||
},
|
||||
[detailData]
|
||||
);
|
||||
|
||||
// Add effect to load detail data when metric is selected
|
||||
useEffect(() => {
|
||||
if (selectedMetric) {
|
||||
fetchDetailData(selectedMetric);
|
||||
}
|
||||
}, [selectedMetric, fetchDetailData]);
|
||||
|
||||
// Add preload effect with throttling
|
||||
useEffect(() => {
|
||||
// Preload detail data with throttling to avoid overwhelming the server
|
||||
const preloadData = async () => {
|
||||
const metrics = ["revenue", "orders", "average_order", "shipping"];
|
||||
for (const metric of metrics) {
|
||||
try {
|
||||
await fetchDetailData(metric);
|
||||
// Small delay between requests
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
} catch (error) {
|
||||
console.error(`Error preloading ${metric}:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
preloadData();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<Card className="h-[150px] bg-gradient-to-br from-emerald-900 to-emerald-800 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className="text-emerald-100 font-bold text-md">
|
||||
<Skeleton className="h-4 w-24 bg-emerald-700" />
|
||||
</CardTitle>
|
||||
<div className="relative p-2">
|
||||
<div className="absolute inset-0 rounded-full bg-emerald-300" />
|
||||
<Skeleton className="h-5 w-5 bg-emerald-700 relative rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-20 bg-emerald-700" />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-4 w-24 bg-emerald-700" />
|
||||
<Skeleton className="h-4 w-12 bg-emerald-700 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="h-[150px] bg-gradient-to-br from-blue-900 to-blue-800 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className="text-blue-100 font-bold text-md">
|
||||
<Skeleton className="h-4 w-24 bg-blue-700" />
|
||||
</CardTitle>
|
||||
<div className="relative p-2">
|
||||
<div className="absolute inset-0 rounded-full bg-blue-300" />
|
||||
<Skeleton className="h-5 w-5 bg-blue-700 relative rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-20 bg-blue-700" />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-4 w-24 bg-blue-700" />
|
||||
<Skeleton className="h-4 w-12 bg-blue-700 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="h-[150px] bg-gradient-to-br from-violet-900 to-violet-800 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className="text-violet-100 font-bold text-md">
|
||||
<Skeleton className="h-4 w-24 bg-violet-700" />
|
||||
</CardTitle>
|
||||
<div className="relative p-2">
|
||||
<div className="absolute inset-0 rounded-full bg-violet-300" />
|
||||
<Skeleton className="h-5 w-5 bg-violet-700 relative rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-20 bg-violet-700" />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-4 w-24 bg-violet-700" />
|
||||
<Skeleton className="h-4 w-12 bg-violet-700 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="h-[150px] bg-gradient-to-br from-orange-900 to-orange-800 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className="text-orange-100 font-bold text-md">
|
||||
<Skeleton className="h-4 w-24 bg-orange-700" />
|
||||
</CardTitle>
|
||||
<div className="relative p-2">
|
||||
<div className="absolute inset-0 rounded-full bg-orange-300" />
|
||||
<Skeleton className="h-5 w-5 bg-orange-700 relative rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-20 bg-orange-700" />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-4 w-24 bg-orange-700" />
|
||||
<Skeleton className="h-4 w-12 bg-orange-700 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>Failed to load stats: {error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const revenueTrend = calculateRevenueTrend();
|
||||
const orderTrend = calculateOrderTrend();
|
||||
const aovTrend = calculateAOVTrend();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<StatCard
|
||||
title="Today's Revenue"
|
||||
value={formatCurrency(stats?.revenue || 0)}
|
||||
description={
|
||||
stats?.periodProgress < 100 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Proj: </span>
|
||||
{projectionLoading ? (
|
||||
<div className="w-20">
|
||||
<Skeleton className="h-4 w-15 bg-emerald-700" />
|
||||
</div>
|
||||
) : (
|
||||
formatCurrency(
|
||||
projection?.projectedRevenue || stats.projectedRevenue
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
progress={stats?.periodProgress < 100 ? stats.periodProgress : undefined}
|
||||
trend={projectionLoading && stats?.periodProgress < 100 ? undefined : revenueTrend?.trend}
|
||||
trendValue={
|
||||
projectionLoading && stats?.periodProgress < 100 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-4 w-4 bg-emerald-700 rounded-full" />
|
||||
<Skeleton className="h-4 w-8 bg-emerald-700" />
|
||||
</div>
|
||||
) : revenueTrend?.value ? (
|
||||
formatPercentage(revenueTrend.value)
|
||||
) : null
|
||||
}
|
||||
colorClass="text-emerald-200"
|
||||
titleClass="text-emerald-100 font-bold text-md"
|
||||
descriptionClass="text-emerald-200 text-md font-semibold"
|
||||
icon={DollarSign}
|
||||
iconColor="text-emerald-900"
|
||||
iconBackground="bg-emerald-300"
|
||||
onDetailsClick={() => setSelectedMetric("revenue")}
|
||||
isLoading={loading || !stats}
|
||||
variant="mini"
|
||||
background="h-[150px] bg-gradient-to-br from-emerald-900 to-emerald-800"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Today's Orders"
|
||||
value={stats?.orderCount}
|
||||
description={`${stats?.itemCount} total items`}
|
||||
trend={orderTrend?.trend}
|
||||
trendValue={orderTrend?.value ? formatPercentage(orderTrend.value) : null}
|
||||
colorClass="text-blue-200"
|
||||
titleClass="text-blue-100 font-bold text-md"
|
||||
descriptionClass="text-blue-200 text-md font-semibold"
|
||||
icon={ShoppingCart}
|
||||
iconColor="text-blue-900"
|
||||
iconBackground="bg-blue-300"
|
||||
onDetailsClick={() => setSelectedMetric("orders")}
|
||||
isLoading={loading || !stats}
|
||||
variant="mini"
|
||||
background="h-[150px] bg-gradient-to-br from-blue-900 to-blue-800"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Today's AOV"
|
||||
value={stats?.averageOrderValue?.toFixed(2)}
|
||||
valuePrefix="$"
|
||||
description={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
|
||||
trend={aovTrend?.trend}
|
||||
trendValue={aovTrend?.value ? formatPercentage(aovTrend.value) : null}
|
||||
colorClass="text-violet-200"
|
||||
titleClass="text-violet-100 font-bold text-md"
|
||||
descriptionClass="text-violet-200 text-md font-semibold"
|
||||
icon={CircleDollarSign}
|
||||
iconColor="text-violet-900"
|
||||
iconBackground="bg-violet-300"
|
||||
onDetailsClick={() => setSelectedMetric("average_order")}
|
||||
isLoading={loading || !stats}
|
||||
variant="mini"
|
||||
background="h-[150px] bg-gradient-to-br from-violet-900 to-violet-800"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Shipped Today"
|
||||
value={stats?.shipping?.shippedCount || 0}
|
||||
description={`${stats?.shipping?.locations?.total || 0} locations`}
|
||||
colorClass="text-orange-200"
|
||||
titleClass="text-orange-100 font-bold text-md"
|
||||
descriptionClass="text-orange-200 text-md font-semibold"
|
||||
icon={Package}
|
||||
iconColor="text-orange-900"
|
||||
iconBackground="bg-orange-300"
|
||||
onDetailsClick={() => setSelectedMetric("shipping")}
|
||||
isLoading={loading || !stats}
|
||||
variant="mini"
|
||||
background="h-[150px] bg-gradient-to-br from-orange-900 to-orange-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={!!selectedMetric}
|
||||
onOpenChange={() => setSelectedMetric(null)}
|
||||
>
|
||||
<DialogContent className={`w-[80vw] h-[80vh] max-w-none p-0 ${
|
||||
selectedMetric === 'revenue' ? 'bg-emerald-50 dark:bg-emerald-950/30' :
|
||||
selectedMetric === 'orders' ? 'bg-blue-50 dark:bg-blue-950/30' :
|
||||
selectedMetric === 'average_order' ? 'bg-violet-50 dark:bg-violet-950/30' :
|
||||
selectedMetric === 'shipping' ? 'bg-orange-50 dark:bg-orange-950/30' :
|
||||
'bg-white dark:bg-gray-950'
|
||||
} backdrop-blur-md border-none`}>
|
||||
<div className="transform scale-[2] origin-top-left h-[40vh] w-[40vw]">
|
||||
<div className="h-full w-full p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className={`text-2xl font-bold ${
|
||||
selectedMetric === 'revenue' ? 'text-emerald-900 dark:text-emerald-100' :
|
||||
selectedMetric === 'orders' ? 'text-blue-900 dark:text-blue-100' :
|
||||
selectedMetric === 'average_order' ? 'text-violet-900 dark:text-violet-100' :
|
||||
selectedMetric === 'shipping' ? 'text-orange-900 dark:text-orange-100' :
|
||||
'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
{selectedMetric
|
||||
? `${selectedMetric
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")} Details`
|
||||
: ""}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 h-[calc(40vh-4rem)] overflow-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
|
||||
{detailDataLoading[selectedMetric] ? (
|
||||
<div className="space-y-4 h-full">
|
||||
{selectedMetric === "shipping" ? (
|
||||
<MiniSkeletonTable
|
||||
rows={8}
|
||||
colorScheme={
|
||||
selectedMetric === 'revenue' ? 'emerald' :
|
||||
selectedMetric === 'orders' ? 'blue' :
|
||||
selectedMetric === 'average_order' ? 'violet' :
|
||||
'orange'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<MiniSkeletonChart
|
||||
type={selectedMetric === "orders" ? "bar" : "line"}
|
||||
metric={selectedMetric}
|
||||
/>
|
||||
{selectedMetric === "orders" && (
|
||||
<div className="mt-8">
|
||||
<h3 className={`text-lg font-medium mb-4 ${
|
||||
selectedMetric === 'revenue' ? 'text-emerald-900 dark:text-emerald-200' :
|
||||
selectedMetric === 'orders' ? 'text-blue-900 dark:text-blue-200' :
|
||||
selectedMetric === 'average_order' ? 'text-violet-900 dark:text-violet-200' :
|
||||
selectedMetric === 'shipping' ? 'text-orange-900 dark:text-orange-200' :
|
||||
'text-gray-900 dark:text-gray-200'
|
||||
}`}>
|
||||
Hourly Distribution
|
||||
</h3>
|
||||
<MiniSkeletonChart type="bar" metric={selectedMetric} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full">
|
||||
{selectedMetric === "revenue" && (
|
||||
<RevenueDetails
|
||||
data={detailData.revenue || []}
|
||||
colorScheme="emerald"
|
||||
/>
|
||||
)}
|
||||
{selectedMetric === "orders" && (
|
||||
<OrdersDetails
|
||||
data={detailData.orders || []}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
)}
|
||||
{selectedMetric === "average_order" && (
|
||||
<AverageOrderDetails
|
||||
data={detailData.average_order || []}
|
||||
orderCount={stats.orderCount}
|
||||
colorScheme="violet"
|
||||
/>
|
||||
)}
|
||||
{selectedMetric === "shipping" && (
|
||||
<ShippingDetails
|
||||
data={[stats]}
|
||||
timeRange={timeRange}
|
||||
colorScheme="orange"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniStatCards;
|
||||
268
inventory/src/components/dashboard/Navigation.jsx
Normal file
268
inventory/src/components/dashboard/Navigation.jsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/dashboard/ui/button";
|
||||
import { Card, CardContent } from "@/components/dashboard/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useScroll } from "@/contexts/DashboardScrollContext";
|
||||
import { ArrowUpToLine } from "lucide-react";
|
||||
|
||||
const Navigation = () => {
|
||||
const [activeSections, setActiveSections] = useState([]);
|
||||
const { isStuck, scrollContainerRef, scrollToSection } = useScroll();
|
||||
const buttonRefs = useRef({});
|
||||
const scrollContainerRef2 = useRef(null);
|
||||
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
|
||||
const lastScrollLeft = useRef(0);
|
||||
const lastScrollTop = useRef(0);
|
||||
|
||||
// Define base sections that are always visible
|
||||
const baseSections = [
|
||||
{ id: "stats", label: "Statistics" },
|
||||
{ id: "realtime", label: "Realtime" },
|
||||
{ id: "feed", label: "Event Feed" },
|
||||
{ id: "sales", label: "Sales Chart" },
|
||||
{ id: "products", label: "Top Products" },
|
||||
{ id: "campaigns", label: "Campaigns" },
|
||||
{ id: "analytics", label: "Analytics" },
|
||||
{ id: "user-behavior", label: "User Behavior" },
|
||||
{ id: "meta-campaigns", label: "Meta Ads" },
|
||||
{ id: "typeform", label: "Customer Surveys" },
|
||||
{ id: "gorgias-overview", label: "Customer Service" },
|
||||
{ id: "calls", label: "Calls" },
|
||||
];
|
||||
|
||||
const sortSections = (sections) => {
|
||||
const isMediumScreen = window.matchMedia(
|
||||
"(min-width: 768px) and (max-width: 1023px)"
|
||||
).matches;
|
||||
|
||||
return [...sections].sort((a, b) => {
|
||||
const aOrder = a.order
|
||||
? isMediumScreen
|
||||
? a.order.md
|
||||
: a.order.default
|
||||
: 0;
|
||||
const bOrder = b.order
|
||||
? isMediumScreen
|
||||
? b.order.md
|
||||
: b.order.default
|
||||
: 0;
|
||||
|
||||
if (aOrder && bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
const sections = sortSections(baseSections);
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
} else {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSectionClick = (sectionId, responsiveIds) => {
|
||||
scrollToSection(sectionId);
|
||||
};
|
||||
|
||||
// Track horizontal scroll position changes
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleButtonBarScroll = () => {
|
||||
if (Math.abs(container.scrollLeft - lastScrollLeft.current) > 5) {
|
||||
setShouldAutoScroll(false);
|
||||
}
|
||||
lastScrollLeft.current = container.scrollLeft;
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleButtonBarScroll);
|
||||
return () => container.removeEventListener("scroll", handleButtonBarScroll);
|
||||
}, []);
|
||||
|
||||
// Handle page scroll and active sections
|
||||
useEffect(() => {
|
||||
const handlePageScroll = (e) => {
|
||||
const scrollTop = e?.target?.scrollTop || window.pageYOffset || document.documentElement.scrollTop;
|
||||
|
||||
if (Math.abs(scrollTop - lastScrollTop.current) > 5) {
|
||||
setShouldAutoScroll(true);
|
||||
lastScrollTop.current = scrollTop;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIds = [];
|
||||
const viewportHeight = window.innerHeight;
|
||||
const threshold = viewportHeight * 0.5;
|
||||
const container = scrollContainerRef.current;
|
||||
|
||||
sections.forEach((section) => {
|
||||
if (section.responsiveIds) {
|
||||
const visibleId = section.responsiveIds.find((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
if (style.display === "none") return false;
|
||||
|
||||
if (container) {
|
||||
// For container-based scrolling
|
||||
const rect = element.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const relativeTop = rect.top - containerRect.top;
|
||||
const relativeBottom = rect.bottom - containerRect.top;
|
||||
return (
|
||||
relativeTop < containerRect.height - threshold &&
|
||||
relativeBottom > threshold
|
||||
);
|
||||
} else {
|
||||
// For window-based scrolling
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.top < viewportHeight - threshold && rect.bottom > threshold
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (visibleId) {
|
||||
activeIds.push(section.id);
|
||||
}
|
||||
} else {
|
||||
const element = document.getElementById(section.id);
|
||||
if (element) {
|
||||
if (container) {
|
||||
// For container-based scrolling
|
||||
const rect = element.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const relativeTop = rect.top - containerRect.top;
|
||||
const relativeBottom = rect.bottom - containerRect.top;
|
||||
if (
|
||||
relativeTop < containerRect.height - threshold &&
|
||||
relativeBottom > threshold
|
||||
) {
|
||||
activeIds.push(section.id);
|
||||
}
|
||||
} else {
|
||||
// For window-based scrolling
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
rect.top < viewportHeight - threshold &&
|
||||
rect.bottom > threshold
|
||||
) {
|
||||
activeIds.push(section.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setActiveSections(activeIds);
|
||||
|
||||
if (shouldAutoScroll && activeIds.length > 0) {
|
||||
const firstActiveButton = buttonRefs.current[activeIds[0]];
|
||||
if (firstActiveButton && scrollContainerRef2.current) {
|
||||
scrollContainerRef2.current.scrollTo({
|
||||
left:
|
||||
firstActiveButton.offsetLeft -
|
||||
scrollContainerRef2.current.offsetWidth / 2 +
|
||||
firstActiveButton.offsetWidth / 2,
|
||||
behavior: "auto",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Attach to container or window
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener("scroll", handlePageScroll);
|
||||
handlePageScroll({ target: container });
|
||||
} else {
|
||||
window.addEventListener("scroll", handlePageScroll);
|
||||
handlePageScroll();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (container) {
|
||||
container.removeEventListener("scroll", handlePageScroll);
|
||||
} else {
|
||||
window.removeEventListener("scroll", handlePageScroll);
|
||||
}
|
||||
};
|
||||
}, [sections, shouldAutoScroll, scrollContainerRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky z-50 px-4 transition-all duration-200",
|
||||
isStuck ? "top-1 sm:top-2 md:top-4 rounded-lg" : "rounded-t-none"
|
||||
)}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full bg-white dark:bg-gray-900 transition-all duration-200",
|
||||
isStuck
|
||||
? "rounded-lg mt-2 shadow-md"
|
||||
: "shadow-sm rounded-t-none border-t-0 -mt-6 pb-2"
|
||||
)}
|
||||
>
|
||||
<CardContent className="py-2 px-4">
|
||||
<div className="grid grid-cols-[1fr_auto] items-center min-w-0 relative">
|
||||
<div
|
||||
ref={scrollContainerRef2}
|
||||
className="overflow-x-auto no-scrollbar min-w-0 -mx-1 px-1 touch-pan-x overscroll-y-contain pr-12"
|
||||
>
|
||||
<div className="flex flex-nowrap space-x-1">
|
||||
{sections.map(({ id, label, responsiveIds }) => (
|
||||
<Button
|
||||
key={id}
|
||||
ref={(el) => (buttonRefs.current[id] = el)}
|
||||
variant={activeSections.includes(id) ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"whitespace-nowrap flex-shrink-0 px-1 md:px-3 py-2 transition-all duration-200",
|
||||
activeSections.includes(id) &&
|
||||
"bg-blue-100 dark:bg-blue-900/70 text-primary dark:text-blue-100 shadow-sm hover:bg-blue-100 dark:hover:bg-blue-900/70 md:hover:bg-blue-200 dark:md:hover:bg-blue-900",
|
||||
!activeSections.includes(id) &&
|
||||
"hover:bg-blue-100 dark:hover:bg-blue-900/40 md:hover:bg-blue-50 dark:md:hover:bg-blue-900/20 hover:text-primary dark:hover:text-blue-100 dark:text-gray-400",
|
||||
"focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring focus-visible:ring-offset-background",
|
||||
"disabled:pointer-events-none disabled:opacity-50"
|
||||
)}
|
||||
onClick={() => handleSectionClick(id, responsiveIds)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -right-2.5 top-0 bottom-0 flex items-center bg-white dark:bg-gray-900 pl-1 pr-0">
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"flex-shrink-0 h-10 w-10 p-0 hover:bg-blue-100 dark:hover:bg-blue-900/40",
|
||||
isStuck ? "" : "hidden"
|
||||
)}
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
<ArrowUpToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navigation;
|
||||
203
inventory/src/components/dashboard/PinProtection.jsx
Normal file
203
inventory/src/components/dashboard/PinProtection.jsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/dashboard/ui/input-otp"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/dashboard/ui/card";
|
||||
import { Button } from "@/components/dashboard/ui/button";
|
||||
import { Lock, Delete } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
const PinProtection = ({ onSuccess }) => {
|
||||
const [pin, setPin] = useState("");
|
||||
const [attempts, setAttempts] = useState(() => {
|
||||
return parseInt(localStorage.getItem('pinAttempts') || '0');
|
||||
});
|
||||
const [lockoutTime, setLockoutTime] = useState(() => {
|
||||
const lastAttempt = localStorage.getItem('lastAttemptTime');
|
||||
if (!lastAttempt) return 0;
|
||||
|
||||
const timeSinceLastAttempt = Date.now() - parseInt(lastAttempt);
|
||||
if (timeSinceLastAttempt < LOCKOUT_DURATION) {
|
||||
return LOCKOUT_DURATION - timeSinceLastAttempt;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
if (lockoutTime > 0) {
|
||||
timer = setInterval(() => {
|
||||
setLockoutTime(prev => {
|
||||
const newTime = prev - 1000;
|
||||
if (newTime <= 0) {
|
||||
localStorage.removeItem('pinAttempts');
|
||||
localStorage.removeItem('lastAttemptTime');
|
||||
return 0;
|
||||
}
|
||||
return newTime;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [lockoutTime]);
|
||||
|
||||
const handleComplete = useCallback((value) => {
|
||||
if (lockoutTime > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newAttempts = attempts + 1;
|
||||
setAttempts(newAttempts);
|
||||
localStorage.setItem('pinAttempts', newAttempts.toString());
|
||||
localStorage.setItem('lastAttemptTime', Date.now().toString());
|
||||
|
||||
if (newAttempts >= MAX_ATTEMPTS) {
|
||||
setLockoutTime(LOCKOUT_DURATION);
|
||||
toast({
|
||||
title: "Too many attempts",
|
||||
description: `Please try again in ${Math.ceil(LOCKOUT_DURATION / 60000)} minutes`,
|
||||
variant: "destructive",
|
||||
});
|
||||
setPin("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === "123456") {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "PIN accepted",
|
||||
});
|
||||
// Reset attempts on success
|
||||
setAttempts(0);
|
||||
localStorage.removeItem('pinAttempts');
|
||||
localStorage.removeItem('lastAttemptTime');
|
||||
onSuccess();
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Incorrect PIN. ${MAX_ATTEMPTS - newAttempts} attempts remaining`,
|
||||
variant: "destructive",
|
||||
});
|
||||
setPin("");
|
||||
}
|
||||
}, [attempts, lockoutTime, onSuccess, toast]);
|
||||
|
||||
const handleKeyPress = (value) => {
|
||||
if (pin.length < 6) {
|
||||
const newPin = pin + value;
|
||||
setPin(newPin);
|
||||
if (newPin.length === 6) {
|
||||
handleComplete(newPin);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setPin(prev => prev.slice(0, -1));
|
||||
};
|
||||
|
||||
const renderKeypad = () => {
|
||||
const keys = [
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
[7, 8, 9],
|
||||
['clear', 0, 'delete']
|
||||
];
|
||||
|
||||
return keys.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="flex justify-center gap-4">
|
||||
{row.map((key, index) => {
|
||||
if (key === 'delete') {
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
variant="ghost"
|
||||
className="w-16 h-16 text-lg font-medium hover:bg-muted"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Delete className="h-6 w-6" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (key === 'clear') {
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
variant="ghost"
|
||||
className="w-16 h-16 text-lg font-medium hover:bg-muted"
|
||||
onClick={() => setPin("")}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
variant="ghost"
|
||||
className="w-16 h-16 text-2xl font-medium hover:bg-muted"
|
||||
onClick={() => handleKeyPress(key.toString())}
|
||||
>
|
||||
{key}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
// Create masked version of PIN
|
||||
const maskedPin = pin.replace(/./g, '•');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-screen flex items-center justify-center bg-gradient-to-b from-gray-100 to-gray-200 dark:from-gray-900 dark:to-gray-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Lock className="h-12 w-12 text-gray-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">Enter PIN</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{lockoutTime > 0 ? (
|
||||
`Too many attempts. Try again in ${Math.ceil(lockoutTime / 60000)} minutes`
|
||||
) : (
|
||||
"Enter your PIN to access the display"
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={maskedPin}
|
||||
disabled
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{[0,1,2,3,4,5].map((index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
className="w-14 h-14 text-2xl border-2 rounded-lg"
|
||||
readOnly
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{renderKeypad()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PinProtection;
|
||||
401
inventory/src/components/dashboard/ProductGrid.jsx
Normal file
401
inventory/src/components/dashboard/ProductGrid.jsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { acotService } from "@/services/dashboard/acotService";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/dashboard/ui/card";
|
||||
import { ScrollArea } from "@/components/dashboard/ui/scroll-area";
|
||||
import { Loader2, ArrowUpDown, AlertCircle, Package, Settings2, Search, X } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/dashboard/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/dashboard/ui/select";
|
||||
import { Input } from "@/components/dashboard/ui/input";
|
||||
import { Button } from "@/components/dashboard/ui/button";
|
||||
import { TIME_RANGES } from "@/lib/dashboard/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/dashboard/ui/tooltip";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
|
||||
|
||||
const ProductGrid = ({
|
||||
timeRange = "today",
|
||||
onTimeRangeChange,
|
||||
title = "Top Products",
|
||||
description
|
||||
}) => {
|
||||
const [products, setProducts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState(timeRange);
|
||||
const [sorting, setSorting] = useState({
|
||||
column: "totalQuantity",
|
||||
direction: "desc",
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [selectedTimeRange]);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await acotService.getProducts({ timeRange: selectedTimeRange });
|
||||
setProducts(response.stats.products.list || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching products:", error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (value) => {
|
||||
setSelectedTimeRange(value);
|
||||
if (onTimeRangeChange) {
|
||||
onTimeRangeChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (column) => {
|
||||
setSorting((prev) => ({
|
||||
column,
|
||||
direction:
|
||||
prev.column === column && prev.direction === "desc" ? "asc" : "desc",
|
||||
}));
|
||||
};
|
||||
|
||||
const sortedProducts = [...products].sort((a, b) => {
|
||||
const direction = sorting.direction === "desc" ? -1 : 1;
|
||||
const aValue = a[sorting.column];
|
||||
const bValue = b[sorting.column];
|
||||
|
||||
if (typeof aValue === "number") {
|
||||
return (aValue - bValue) * direction;
|
||||
}
|
||||
return String(aValue).localeCompare(String(bValue)) * direction;
|
||||
});
|
||||
|
||||
const filteredProducts = sortedProducts.filter(product =>
|
||||
product.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const SkeletonProduct = () => (
|
||||
<tr className="hover:bg-muted/50 transition-colors">
|
||||
<td className="p-1 align-middle w-[50px]">
|
||||
<Skeleton className="h-[50px] w-[50px] rounded bg-muted" />
|
||||
</td>
|
||||
<td className="p-1 align-middle min-w-[200px]">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-[180px] bg-muted rounded-sm" />
|
||||
<Skeleton className="h-3 w-[140px] bg-muted rounded-sm" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-1 align-middle text-center">
|
||||
<Skeleton className="h-4 w-8 mx-auto bg-muted rounded-sm" />
|
||||
</td>
|
||||
<td className="p-1 align-middle text-center">
|
||||
<Skeleton className="h-4 w-16 mx-auto bg-muted rounded-sm" />
|
||||
</td>
|
||||
<td className="p-1 align-middle text-center">
|
||||
<Skeleton className="h-4 w-8 mx-auto bg-muted rounded-sm" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const LoadingState = () => (
|
||||
<div className="h-full">
|
||||
<div className="overflow-y-auto h-full">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="hover:bg-transparent">
|
||||
<th className="p-1.5 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 w-[50px] min-w-[50px] border-b dark:border-gray-800" />
|
||||
<th className="p-1.5 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 min-w-[200px] border-b dark:border-gray-800">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full p-2 justify-start h-8 pointer-events-none"
|
||||
disabled
|
||||
>
|
||||
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-1.5 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full p-2 justify-center h-8 pointer-events-none"
|
||||
disabled
|
||||
>
|
||||
<Skeleton className="h-4 w-12 bg-muted rounded-sm" />
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-1.5 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full p-2 justify-center h-8 pointer-events-none"
|
||||
disabled
|
||||
>
|
||||
<Skeleton className="h-4 w-12 bg-muted rounded-sm" />
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-1.5 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full p-2 justify-center h-8 pointer-events-none"
|
||||
disabled
|
||||
>
|
||||
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
|
||||
</Button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<SkeletonProduct key={i} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="flex flex-col h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-6 pb-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
<Skeleton className="h-6 w-32 bg-muted rounded-sm" />
|
||||
</CardTitle>
|
||||
{description && (
|
||||
<CardDescription className="mt-1">
|
||||
<Skeleton className="h-4 w-48 bg-muted rounded-sm" />
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-9 bg-muted rounded-sm" />
|
||||
<Skeleton className="h-9 w-[130px] bg-muted rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 pt-0 flex-1 overflow-hidden -mt-1">
|
||||
<div className="h-full">
|
||||
<LoadingState />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-6 pb-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">{title}</CardTitle>
|
||||
{description && (
|
||||
<CardDescription className="mt-1 text-muted-foreground">{description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!error && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsSearchVisible(!isSearchVisible)}
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
isSearchVisible && "bg-muted"
|
||||
)}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Select
|
||||
value={selectedTimeRange}
|
||||
onValueChange={handleTimeRangeChange}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] h-9">
|
||||
<SelectValue placeholder="Select time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_RANGES.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{isSearchVisible && !error && (
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search products..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-9 h-9 w-full"
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1 h-7 w-7"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 pt-0 flex-1 overflow-hidden -mt-1">
|
||||
<div className="h-full">
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Failed to load products: {error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : !products?.length ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="font-medium mb-2 text-gray-900 dark:text-gray-100">No product data available</p>
|
||||
<p className="text-sm text-muted-foreground">Try selecting a different time range</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full">
|
||||
<div className="overflow-y-auto h-full">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="hover:bg-transparent">
|
||||
<th className="p-1 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 h-[50px] min-h-[50px] w-[50px] min-w-[35px] border-b dark:border-gray-800" />
|
||||
<th className="p-1 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<Button
|
||||
variant={sorting.column === "name" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("name")}
|
||||
className="w-full p-2 justify-start h-8"
|
||||
>
|
||||
Product
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-1 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<Button
|
||||
variant={sorting.column === "totalQuantity" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("totalQuantity")}
|
||||
className="w-full p-2 justify-center h-8"
|
||||
>
|
||||
Sold
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-1 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<Button
|
||||
variant={sorting.column === "totalRevenue" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("totalRevenue")}
|
||||
className="w-full p-2 justify-center h-8"
|
||||
>
|
||||
Rev
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-1 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<Button
|
||||
variant={sorting.column === "orderCount" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("orderCount")}
|
||||
className="w-full p-2 justify-center h-8"
|
||||
>
|
||||
Orders
|
||||
</Button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{filteredProducts.map((product) => (
|
||||
<tr
|
||||
key={product.id}
|
||||
className="hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<td className="p-1 align-middle w-[50px]">
|
||||
{product.ImgThumb && (
|
||||
<img
|
||||
src={product.ImgThumb}
|
||||
alt=""
|
||||
width={50}
|
||||
height={50}
|
||||
className="rounded bg-muted w-[50px] h-[50px] object-contain"
|
||||
onError={(e) => (e.target.style.display = "none")}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-1 align-middle min-w-[200px]">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm hover:underline line-clamp-2 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{product.name}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[300px]">
|
||||
<p>{product.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-1 align-middle text-center text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{product.totalQuantity}
|
||||
</td>
|
||||
<td className="p-1 align-middle text-center text-emerald-600 dark:text-emerald-400 text-sm font-medium">
|
||||
${product.totalRevenue.toFixed(2)}
|
||||
</td>
|
||||
<td className="p-1 align-middle text-center text-muted-foreground text-sm">
|
||||
{product.orderCount}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductGrid;
|
||||
633
inventory/src/components/dashboard/RealtimeAnalytics.jsx
Normal file
633
inventory/src/components/dashboard/RealtimeAnalytics.jsx
Normal file
@@ -0,0 +1,633 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { Loader2, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
Tooltip as UITooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TooltipProvider,
|
||||
} from "@/components/dashboard/ui/tooltip";
|
||||
import { Alert, AlertDescription } from "@/components/dashboard/ui/alert";
|
||||
import { Button } from "@/components/dashboard/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/dashboard/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/dashboard/ui/table";
|
||||
import { format } from "date-fns";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
|
||||
export const METRIC_COLORS = {
|
||||
activeUsers: {
|
||||
color: "#8b5cf6",
|
||||
className: "text-purple-600 dark:text-purple-400",
|
||||
},
|
||||
pages: {
|
||||
color: "#10b981",
|
||||
className: "text-emerald-600 dark:text-emerald-400",
|
||||
},
|
||||
sources: {
|
||||
color: "#f59e0b",
|
||||
className: "text-amber-600 dark:text-amber-400",
|
||||
},
|
||||
};
|
||||
|
||||
export const summaryCard = (label, sublabel, value, options = {}) => {
|
||||
const {
|
||||
colorClass = "text-gray-900 dark:text-gray-100",
|
||||
titleClass = "text-sm font-medium text-gray-500 dark:text-gray-400",
|
||||
descriptionClass = "text-sm text-gray-600 dark:text-gray-300",
|
||||
background = "bg-white dark:bg-gray-900/60",
|
||||
icon: Icon,
|
||||
iconColor,
|
||||
iconBackground
|
||||
} = options;
|
||||
|
||||
return (
|
||||
<Card className={`w-full ${background} backdrop-blur-sm`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-2">
|
||||
<CardTitle className={titleClass}>
|
||||
{label}
|
||||
</CardTitle>
|
||||
{Icon && (
|
||||
<div className="relative p-2">
|
||||
<div className={`absolute inset-0 rounded-full ${iconBackground}`} />
|
||||
<Icon className={`h-5 w-5 ${iconColor} relative`} />
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pt-0 pb-2">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className={`text-3xl font-extrabold ${colorClass}`}>
|
||||
{value.toLocaleString()}
|
||||
</div>
|
||||
<div className={descriptionClass}>
|
||||
{sublabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const SkeletonSummaryCard = () => (
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-2">
|
||||
<Skeleton className="h-4 w-24 bg-muted" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pt-0 pb-2">
|
||||
<Skeleton className="h-8 w-20 mb-1 bg-muted" />
|
||||
<Skeleton className="h-4 w-32 bg-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export const SkeletonBarChart = () => (
|
||||
<div className="h-[235px] bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-4">
|
||||
<div className="h-full relative">
|
||||
{/* Grid lines */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-full h-px bg-muted"
|
||||
style={{ top: `${(i + 1) * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-6 bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
{/* X-axis labels */}
|
||||
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-8 bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
{/* Bars */}
|
||||
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between">
|
||||
{[...Array(30)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 bg-muted"
|
||||
style={{
|
||||
height: `${Math.random() * 80 + 10}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SkeletonTable = () => (
|
||||
<div className="space-y-2 h-[230px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead>
|
||||
<Skeleton className="h-4 w-32 bg-muted" />
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<Skeleton className="h-4 w-24 ml-auto bg-muted" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<TableRow key={i} className="dark:border-gray-800">
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-48 bg-muted" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className="h-4 w-12 ml-auto bg-muted" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const processBasicData = (data) => {
|
||||
const last30MinUsers = parseInt(
|
||||
data.userResponse?.rows?.[0]?.metricValues?.[0]?.value || 0
|
||||
);
|
||||
const last5MinUsers = parseInt(
|
||||
data.fiveMinResponse?.rows?.[0]?.metricValues?.[0]?.value || 0
|
||||
);
|
||||
|
||||
const byMinute = Array.from({ length: 30 }, (_, i) => {
|
||||
const matchingRow = data.timeSeriesResponse?.rows?.find(
|
||||
(row) => parseInt(row.dimensionValues[0].value) === i
|
||||
);
|
||||
const users = matchingRow
|
||||
? parseInt(matchingRow.metricValues[0].value)
|
||||
: 0;
|
||||
const timestamp = new Date(Date.now() - i * 60000);
|
||||
return {
|
||||
minute: -i,
|
||||
users,
|
||||
timestamp: timestamp.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
};
|
||||
}).reverse();
|
||||
|
||||
const tokenQuota = data.quotaInfo
|
||||
? {
|
||||
projectHourly: data.quotaInfo.projectHourly || {},
|
||||
daily: data.quotaInfo.daily || {},
|
||||
serverErrors: data.quotaInfo.serverErrors || {},
|
||||
thresholdedRequests: data.quotaInfo.thresholdedRequests || {},
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
last30MinUsers,
|
||||
last5MinUsers,
|
||||
byMinute,
|
||||
tokenQuota,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const QuotaInfo = ({ tokenQuota }) => {
|
||||
if (!tokenQuota || typeof tokenQuota !== "object") return null;
|
||||
|
||||
const {
|
||||
projectHourly = {},
|
||||
daily = {},
|
||||
serverErrors = {},
|
||||
thresholdedRequests = {},
|
||||
} = tokenQuota;
|
||||
|
||||
const {
|
||||
remaining: projectHourlyRemaining = 0,
|
||||
consumed: projectHourlyConsumed = 0,
|
||||
} = projectHourly;
|
||||
|
||||
const { remaining: dailyRemaining = 0, consumed: dailyConsumed = 0 } = daily;
|
||||
|
||||
const { remaining: errorsRemaining = 10, consumed: errorsConsumed = 0 } =
|
||||
serverErrors;
|
||||
|
||||
const {
|
||||
remaining: thresholdRemaining = 120,
|
||||
consumed: thresholdConsumed = 0,
|
||||
} = thresholdedRequests;
|
||||
|
||||
const hourlyPercentage = ((projectHourlyRemaining / 14000) * 100).toFixed(1);
|
||||
const dailyPercentage = ((dailyRemaining / 200000) * 100).toFixed(1);
|
||||
const errorPercentage = ((errorsRemaining / 10) * 100).toFixed(1);
|
||||
const thresholdPercentage = ((thresholdRemaining / 120) * 100).toFixed(1);
|
||||
|
||||
const getStatusColor = (percentage) => {
|
||||
const numericPercentage = parseFloat(percentage);
|
||||
if (isNaN(numericPercentage) || numericPercentage < 20)
|
||||
return "text-red-500 dark:text-red-400";
|
||||
if (numericPercentage < 40) return "text-yellow-500 dark:text-yellow-400";
|
||||
return "text-green-500 dark:text-green-400";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center font-semibold rounded-md space-x-1">
|
||||
<span>Quota:</span>
|
||||
<span className={`font-medium ${getStatusColor(hourlyPercentage)}`}>
|
||||
{hourlyPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="dark:border-gray-700">
|
||||
<div className="space-y-3 mt-2">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-100">
|
||||
Project Hourly
|
||||
</div>
|
||||
<div className={`${getStatusColor(hourlyPercentage)}`}>
|
||||
{projectHourlyRemaining.toLocaleString()} / 14,000 remaining
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-100">
|
||||
Daily
|
||||
</div>
|
||||
<div className={`${getStatusColor(dailyPercentage)}`}>
|
||||
{dailyRemaining.toLocaleString()} / 200,000 remaining
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-100">
|
||||
Server Errors
|
||||
</div>
|
||||
<div className={`${getStatusColor(errorPercentage)}`}>
|
||||
{errorsConsumed} / 10 used this hour
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-100">
|
||||
Thresholded Requests
|
||||
</div>
|
||||
<div className={`${getStatusColor(thresholdPercentage)}`}>
|
||||
{thresholdConsumed} / 120 used this hour
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const RealtimeAnalytics = () => {
|
||||
const [basicData, setBasicData] = useState({
|
||||
last30MinUsers: 0,
|
||||
last5MinUsers: 0,
|
||||
byMinute: [],
|
||||
tokenQuota: null,
|
||||
lastUpdated: null,
|
||||
});
|
||||
|
||||
const [detailedData, setDetailedData] = useState({
|
||||
currentPages: [],
|
||||
sources: [],
|
||||
recentEvents: [],
|
||||
lastUpdated: null,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const processDetailedData = (data) => {
|
||||
return {
|
||||
currentPages:
|
||||
data.pageResponse?.rows?.map((row) => ({
|
||||
path: row.dimensionValues[0].value,
|
||||
activeUsers: parseInt(row.metricValues[0].value),
|
||||
})) || [],
|
||||
|
||||
sources:
|
||||
data.sourceResponse?.rows?.map((row) => ({
|
||||
source: row.dimensionValues[0].value,
|
||||
activeUsers: parseInt(row.metricValues[0].value),
|
||||
})) || [],
|
||||
|
||||
recentEvents:
|
||||
data.eventResponse?.rows
|
||||
?.filter(
|
||||
(row) =>
|
||||
!["session_start", "(other)"].includes(
|
||||
row.dimensionValues[0].value
|
||||
)
|
||||
)
|
||||
.map((row) => ({
|
||||
event: row.dimensionValues[0].value,
|
||||
count: parseInt(row.metricValues[0].value),
|
||||
})) || [],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let basicInterval;
|
||||
let detailedInterval;
|
||||
|
||||
const fetchBasicData = async () => {
|
||||
if (isPaused) return;
|
||||
try {
|
||||
const response = await fetch("/api/dashboard-analytics/realtime/basic", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch basic realtime data");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const processed = processBasicData(result.data);
|
||||
setBasicData(processed);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error details:", {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
response: error.response,
|
||||
});
|
||||
if (error.message === "QUOTA_EXCEEDED") {
|
||||
setError("Quota exceeded. Analytics paused until manually resumed.");
|
||||
setIsPaused(true);
|
||||
} else {
|
||||
setError("Failed to fetch analytics data");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDetailedData = async () => {
|
||||
if (isPaused) return;
|
||||
try {
|
||||
const response = await fetch("/api/dashboard-analytics/realtime/detailed", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch detailed realtime data");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const processed = processDetailedData(result.data);
|
||||
setDetailedData(processed);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch detailed realtime data:", error);
|
||||
if (error.message === "QUOTA_EXCEEDED") {
|
||||
setError("Quota exceeded. Analytics paused until manually resumed.");
|
||||
setIsPaused(true);
|
||||
} else {
|
||||
setError("Failed to fetch analytics data");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetches
|
||||
fetchBasicData();
|
||||
fetchDetailedData();
|
||||
|
||||
// Set up intervals
|
||||
basicInterval = setInterval(fetchBasicData, 30000); // 30 seconds
|
||||
detailedInterval = setInterval(fetchDetailedData, 300000); // 5 minutes
|
||||
|
||||
return () => {
|
||||
clearInterval(basicInterval);
|
||||
clearInterval(detailedInterval);
|
||||
};
|
||||
}, [isPaused]);
|
||||
|
||||
const togglePause = () => {
|
||||
setIsPaused(!isPaused);
|
||||
};
|
||||
|
||||
if (loading && !basicData && !detailedData) {
|
||||
return (
|
||||
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
|
||||
<CardHeader className="p-6 pb-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Real-Time Analytics
|
||||
</CardTitle>
|
||||
<Skeleton className="h-4 w-32 bg-muted" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 pt-0">
|
||||
<div className="grid grid-cols-2 gap-2 md:gap-3 mt-1 mb-3">
|
||||
<SkeletonSummaryCard />
|
||||
<SkeletonSummaryCard />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-20 bg-muted rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<SkeletonBarChart />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
|
||||
<CardHeader className="p-6 pb-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Real-Time Analytics
|
||||
</CardTitle>
|
||||
<div className="flex items-end">
|
||||
<TooltipProvider>
|
||||
<UITooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Last updated:{" "}
|
||||
{format(new Date(basicData.lastUpdated), "h:mm a")}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="p-3">
|
||||
<QuotaInfo tokenQuota={basicData.tokenQuota} />
|
||||
</TooltipContent>
|
||||
</UITooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 pt-0">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-1 mb-3">
|
||||
{summaryCard(
|
||||
"Last 30 minutes",
|
||||
"Active users",
|
||||
basicData.last30MinUsers,
|
||||
{ colorClass: METRIC_COLORS.activeUsers.className }
|
||||
)}
|
||||
{summaryCard(
|
||||
"Last 5 minutes",
|
||||
"Active users",
|
||||
basicData.last5MinUsers,
|
||||
{ colorClass: METRIC_COLORS.activeUsers.className }
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="activity" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
<TabsTrigger value="pages">Current Pages</TabsTrigger>
|
||||
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<div className="h-[235px] bg-card rounded-lg">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={basicData.byMinute}
|
||||
margin={{ top: 5, right: 5, left: -35, bottom: -5 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="minute"
|
||||
tickFormatter={(value) => value + "m"}
|
||||
className="text-xs"
|
||||
tick={{ fill: "currentColor" }}
|
||||
/>
|
||||
<YAxis className="text-xs" tick={{ fill: "currentColor" }} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const timestamp = new Date(
|
||||
Date.now() + payload[0].payload.minute * 60000
|
||||
);
|
||||
return (
|
||||
<Card className="p-3 shadow-lg bg-white dark:bg-gray-800 border-none">
|
||||
<CardContent className="p-0 space-y-2">
|
||||
<p className="font-medium text-sm border-b pb-1 mb-2">
|
||||
{format(timestamp, "h:mm a")}
|
||||
</p>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span
|
||||
style={{
|
||||
color: METRIC_COLORS.activeUsers.color,
|
||||
}}
|
||||
>
|
||||
Active Users:
|
||||
</span>
|
||||
<span className="font-medium ml-4">
|
||||
{payload[0].value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="users" fill={METRIC_COLORS.activeUsers.color} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pages">
|
||||
<div className="space-y-2 h-[230px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">
|
||||
Page
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||
Active Users
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailedData.currentPages.map((page, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800">
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{page.path}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right ${METRIC_COLORS.pages.className}`}
|
||||
>
|
||||
{page.activeUsers}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sources">
|
||||
<div className="space-y-2 h-[230px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">
|
||||
Source
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||
Active Users
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailedData.sources.map((source, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800">
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{source.source}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right ${METRIC_COLORS.sources.className}`}
|
||||
>
|
||||
{source.activeUsers}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealtimeAnalytics;
|
||||
1127
inventory/src/components/dashboard/SalesChart.jsx
Normal file
1127
inventory/src/components/dashboard/SalesChart.jsx
Normal file
File diff suppressed because it is too large
Load Diff
2320
inventory/src/components/dashboard/StatCards.jsx
Normal file
2320
inventory/src/components/dashboard/StatCards.jsx
Normal file
File diff suppressed because it is too large
Load Diff
700
inventory/src/components/dashboard/TypeformDashboard.jsx
Normal file
700
inventory/src/components/dashboard/TypeformDashboard.jsx
Normal file
@@ -0,0 +1,700 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/dashboard/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/dashboard/ui/table";
|
||||
import { Badge } from "@/components/dashboard/ui/badge";
|
||||
import { ScrollArea } from "@/components/dashboard/ui/scroll-area";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
|
||||
// Get form IDs from environment variables
|
||||
const FORM_IDS = {
|
||||
FORM_1: import.meta.env.VITE_TYPEFORM_FORM_ID_1,
|
||||
FORM_2: import.meta.env.VITE_TYPEFORM_FORM_ID_2,
|
||||
};
|
||||
|
||||
const FORM_NAMES = {
|
||||
[FORM_IDS.FORM_1]: "Product Relevance",
|
||||
[FORM_IDS.FORM_2]: "Winback Survey",
|
||||
};
|
||||
|
||||
// Loading skeleton components
|
||||
const SkeletonChart = () => (
|
||||
<div className="h-[300px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-6">
|
||||
<div className="h-full relative">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-full h-px bg-muted"
|
||||
style={{ top: `${(i + 1) * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-6 bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-16 bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SkeletonTable = () => (
|
||||
<div className="space-y-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[200px]">
|
||||
<Skeleton className="h-4 w-[180px] bg-muted" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-4 w-[100px] bg-muted" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-4 w-[80px] bg-muted" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<TableRow key={i} className="hover:bg-transparent">
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[160px] bg-muted" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[90px] bg-muted" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[70px] bg-muted" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ResponseFeed = ({ responses, title, renderSummary }) => (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{responses.items.map((response) => (
|
||||
<div key={response.token} className="p-4">
|
||||
{renderSummary(response)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const ProductRelevanceFeed = ({ responses }) => (
|
||||
<ResponseFeed
|
||||
responses={responses}
|
||||
title="Product Relevance Responses"
|
||||
renderSummary={(response) => {
|
||||
const answer = response.answers?.find((a) => a.type === "boolean");
|
||||
const textAnswer = response.answers?.find((a) => a.type === "text")?.text;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{response.hidden?.email ? (
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`}
|
||||
className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:underline"
|
||||
>
|
||||
{response.hidden?.name || "Anonymous"}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{response.hidden?.name || "Anonymous"}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
className={
|
||||
answer?.boolean
|
||||
? "bg-green-200 text-green-700"
|
||||
: "bg-red-200 text-red-700"
|
||||
}
|
||||
>
|
||||
{answer?.boolean ? "Yes" : "No"}
|
||||
</Badge>
|
||||
</div>
|
||||
<time
|
||||
className="text-xs text-muted-foreground"
|
||||
dateTime={response.submitted_at}
|
||||
>
|
||||
{format(new Date(response.submitted_at), "MMM d")}
|
||||
</time>
|
||||
</div>
|
||||
{textAnswer && (
|
||||
<div className="text-sm text-muted-foreground">"{textAnswer}"</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const WinbackFeed = ({ responses }) => (
|
||||
<ResponseFeed
|
||||
responses={responses}
|
||||
title="Winback Survey Responses"
|
||||
renderSummary={(response) => {
|
||||
const likelihoodAnswer = response.answers?.find(
|
||||
(a) => a.type === "number"
|
||||
);
|
||||
const reasonsAnswer = response.answers?.find((a) => a.type === "choices");
|
||||
const feedbackAnswer = response.answers?.find(
|
||||
(a) => a.type === "text" && a.field.type === "long_text"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{response.hidden?.email ? (
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`}
|
||||
className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:underline"
|
||||
>
|
||||
{response.hidden?.name || "Anonymous"}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{response.hidden?.name || "Anonymous"}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
className={
|
||||
likelihoodAnswer?.number === 1
|
||||
? "bg-red-200 text-red-700"
|
||||
: likelihoodAnswer?.number === 2
|
||||
? "bg-orange-200 text-orange-700"
|
||||
: likelihoodAnswer?.number === 3
|
||||
? "bg-yellow-200 text-yellow-700"
|
||||
: likelihoodAnswer?.number === 4
|
||||
? "bg-lime-200 text-lime-700"
|
||||
: likelihoodAnswer?.number === 5
|
||||
? "bg-green-200 text-green-700"
|
||||
: "bg-gray-200 text-gray-700"
|
||||
}
|
||||
>
|
||||
{likelihoodAnswer?.number}/5
|
||||
</Badge>
|
||||
</div>
|
||||
<time
|
||||
className="text-xs text-muted-foreground"
|
||||
dateTime={response.submitted_at}
|
||||
>
|
||||
{format(new Date(response.submitted_at), "MMM d")}
|
||||
</time>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(reasonsAnswer?.choices?.labels || []).map((label, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
{reasonsAnswer?.choices?.other && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{reasonsAnswer.choices.other}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{feedbackAnswer?.text && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{feedbackAnswer.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const TypeformDashboard = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
form1: { responses: null, hasMore: false, lastToken: null },
|
||||
form2: { responses: null, hasMore: false, lastToken: null },
|
||||
});
|
||||
|
||||
const fetchResponses = async (formId, before = null) => {
|
||||
const params = { page_size: 1000 };
|
||||
if (before) params.before = before;
|
||||
|
||||
const response = await axios.get(
|
||||
`/api/typeform/forms/${formId}/responses`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFormData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const forms = [FORM_IDS.FORM_1, FORM_IDS.FORM_2];
|
||||
const results = await Promise.all(
|
||||
forms.map(async (formId) => {
|
||||
const responses = await fetchResponses(formId);
|
||||
const hasMore = responses.items.length === 1000;
|
||||
const lastToken = hasMore
|
||||
? responses.items[responses.items.length - 1].token
|
||||
: null;
|
||||
|
||||
return {
|
||||
responses,
|
||||
hasMore,
|
||||
lastToken,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setFormData({
|
||||
form1: results[0],
|
||||
form2: results[1],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error fetching Typeform data:", err);
|
||||
setError("Failed to load form data. Please try again later.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFormData();
|
||||
}, []);
|
||||
|
||||
const calculateMetrics = () => {
|
||||
if (!formData.form1.responses || !formData.form2.responses) return null;
|
||||
|
||||
const form1Responses = formData.form1.responses.items;
|
||||
const form2Responses = formData.form2.responses.items;
|
||||
|
||||
// Product Relevance metrics
|
||||
const yesResponses = form1Responses.filter((r) =>
|
||||
r.answers?.some((a) => a.type === "boolean" && a.boolean === true)
|
||||
).length;
|
||||
const totalForm1 = form1Responses.length;
|
||||
const yesPercentage = Math.round((yesResponses / totalForm1) * 100) || 0;
|
||||
|
||||
// Winback Survey metrics
|
||||
const likelihoodAnswers = form2Responses
|
||||
.map((r) => r.answers?.find((a) => a.type === "number"))
|
||||
.filter(Boolean)
|
||||
.map((a) => a.number);
|
||||
const averageLikelihood = likelihoodAnswers.length
|
||||
? Math.round(
|
||||
(likelihoodAnswers.reduce((a, b) => a + b, 0) /
|
||||
likelihoodAnswers.length) *
|
||||
10
|
||||
) / 10
|
||||
: 0;
|
||||
|
||||
// Get reasons for not ordering (only predefined choices)
|
||||
const reasonsMap = new Map();
|
||||
form2Responses.forEach((response) => {
|
||||
const reasonsAnswer = response.answers?.find((a) => a.type === "choices");
|
||||
if (reasonsAnswer?.choices?.labels) {
|
||||
reasonsAnswer.choices.labels.forEach((label) => {
|
||||
reasonsMap.set(label, (reasonsMap.get(label) || 0) + 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sortedReasons = Array.from(reasonsMap.entries())
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([label, count]) => ({
|
||||
reason: label,
|
||||
count,
|
||||
percentage: Math.round((count / form2Responses.length) * 100),
|
||||
}));
|
||||
|
||||
return {
|
||||
productRelevance: {
|
||||
yesPercentage,
|
||||
yesCount: yesResponses,
|
||||
noCount: totalForm1 - yesResponses,
|
||||
},
|
||||
winback: {
|
||||
averageRating: averageLikelihood,
|
||||
reasons: sortedReasons,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const metrics = loading ? null : calculateMetrics();
|
||||
|
||||
// Find the newest response across both forms
|
||||
const getNewestResponse = () => {
|
||||
if (
|
||||
!formData.form1.responses?.items?.length &&
|
||||
!formData.form2.responses?.items?.length
|
||||
)
|
||||
return null;
|
||||
|
||||
const form1Latest = formData.form1.responses?.items[0]?.submitted_at;
|
||||
const form2Latest = formData.form2.responses?.items[0]?.submitted_at;
|
||||
|
||||
if (!form1Latest) return form2Latest;
|
||||
if (!form2Latest) return form1Latest;
|
||||
|
||||
return new Date(form1Latest) > new Date(form2Latest)
|
||||
? form1Latest
|
||||
: form2Latest;
|
||||
};
|
||||
|
||||
const newestResponse = getNewestResponse();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20">
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate likelihood counts for the chart
|
||||
const likelihoodCounts =
|
||||
!loading && formData.form2.responses
|
||||
? [1, 2, 3, 4, 5].map((rating) => ({
|
||||
rating: rating.toString(),
|
||||
count: formData.form2.responses.items.filter(
|
||||
(r) =>
|
||||
r.answers?.find((a) => a.type === "number")?.number === rating
|
||||
).length,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-6 pb-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Customer Surveys
|
||||
</CardTitle>
|
||||
{newestResponse && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Newest response:{" "}
|
||||
{format(new Date(newestResponse), "MMM d, h:mm a")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<SkeletonChart />
|
||||
<SkeletonTable />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6">
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-6">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
How likely are you to place another order with us?
|
||||
</CardTitle>
|
||||
<span
|
||||
className={`text-2xl font-bold ${
|
||||
metrics.winback.averageRating <= 1
|
||||
? "text-red-600 dark:text-red-500"
|
||||
: metrics.winback.averageRating <= 2
|
||||
? "text-orange-600 dark:text-orange-500"
|
||||
: metrics.winback.averageRating <= 3
|
||||
? "text-yellow-600 dark:text-yellow-500"
|
||||
: metrics.winback.averageRating <= 4
|
||||
? "text-lime-600 dark:text-lime-500"
|
||||
: "text-green-600 dark:text-green-500"
|
||||
}`}
|
||||
>
|
||||
{metrics.winback.averageRating}
|
||||
<span className="text-base font-normal text-muted-foreground">
|
||||
/5 avg
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={likelihoodCounts}
|
||||
margin={{ top: 0, right: 10, left: -20, bottom: -25 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="rating"
|
||||
tickFormatter={(value) => {
|
||||
return value === "1"
|
||||
? "Not at all"
|
||||
: value === "5"
|
||||
? "Extremely"
|
||||
: "";
|
||||
}}
|
||||
textAnchor="middle"
|
||||
interval={0}
|
||||
height={50}
|
||||
className="text-muted-foreground text-xs md:text-sm"
|
||||
/>
|
||||
<YAxis className="text-muted-foreground text-xs md:text-sm" />
|
||||
<Tooltip
|
||||
content={({ payload }) => {
|
||||
if (payload && payload.length) {
|
||||
const { rating, count } = payload[0].payload;
|
||||
return (
|
||||
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border-none">
|
||||
<CardContent className="p-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{rating} Rating: {count} responses
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count">
|
||||
{likelihoodCounts.map((_, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={
|
||||
index === 0
|
||||
? "#ef4444" // red
|
||||
: index === 1
|
||||
? "#f97316" // orange
|
||||
: index === 2
|
||||
? "#eab308" // yellow
|
||||
: index === 3
|
||||
? "#84cc16" // lime
|
||||
: "#10b981" // green
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-6">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Were the suggested products in this email relevant to you?
|
||||
</CardTitle>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-2xl font-bold text-green-600 dark:text-green-500">
|
||||
{metrics.productRelevance.yesPercentage}% Relevant
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[100px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={[
|
||||
{
|
||||
yes: metrics.productRelevance.yesCount,
|
||||
no: metrics.productRelevance.noCount,
|
||||
total:
|
||||
metrics.productRelevance.yesCount +
|
||||
metrics.productRelevance.noCount,
|
||||
},
|
||||
]}
|
||||
layout="vertical"
|
||||
stackOffset="expand"
|
||||
margin={{ top: 0, right: 0, left: -20, bottom: 0 }}
|
||||
>
|
||||
<XAxis type="number" hide domain={[0, 1]} />
|
||||
<YAxis type="category" hide />
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={({ payload }) => {
|
||||
if (payload && payload.length) {
|
||||
const yesCount = payload[0].payload.yes;
|
||||
const noCount = payload[0].payload.no;
|
||||
const total = yesCount + noCount;
|
||||
const yesPercent = Math.round(
|
||||
(yesCount / total) * 100
|
||||
);
|
||||
const noPercent = Math.round(
|
||||
(noCount / total) * 100
|
||||
);
|
||||
return (
|
||||
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border-none">
|
||||
<CardContent className="p-0 space-y-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-emerald-500 font-medium">
|
||||
Yes:
|
||||
</span>
|
||||
<span className="ml-4 text-muted-foreground">
|
||||
{yesCount} ({yesPercent}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-red-500 font-medium">
|
||||
No:
|
||||
</span>
|
||||
<span className="ml-4 text-muted-foreground">
|
||||
{noCount} ({noPercent}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="yes"
|
||||
stackId="stack"
|
||||
fill="#10b981"
|
||||
radius={[0, 0, 0, 0]}
|
||||
>
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
fill="#fff"
|
||||
fontSize={14}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{metrics.productRelevance.yesPercentage}%
|
||||
</text>
|
||||
</Bar>
|
||||
<Bar
|
||||
dataKey="no"
|
||||
stackId="stack"
|
||||
fill="#ef4444"
|
||||
radius={[0, 0, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-md font-semibold mx-1 text-muted-foreground">
|
||||
<div>Yes: {metrics.productRelevance.yesCount}</div>
|
||||
<div>No: {metrics.productRelevance.noCount}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-12 gap-4">
|
||||
<div className="col-span-4 lg:col-span-12 xl:col-span-4">
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Reasons for Not Ordering
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-medium text-gray-900 dark:text-gray-100">
|
||||
Reason
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">
|
||||
Count
|
||||
</TableHead>
|
||||
<TableHead className="text-right w-[80px] font-medium text-gray-900 dark:text-gray-100">
|
||||
%
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{metrics.winback.reasons.map((reason, index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
className="hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{reason.reason}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{reason.count}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{reason.percentage}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-span-4 lg:col-span-6 xl:col-span-4">
|
||||
<WinbackFeed responses={formData.form2.responses} />
|
||||
</div>
|
||||
<div className="col-span-4 lg:col-span-6 xl:col-span-4">
|
||||
<ProductRelevanceFeed responses={formData.form1.responses} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypeformDashboard;
|
||||
412
inventory/src/components/dashboard/UserBehaviorDashboard.jsx
Normal file
412
inventory/src/components/dashboard/UserBehaviorDashboard.jsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/dashboard/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/dashboard/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/dashboard/ui/table";
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
||||
|
||||
// Add skeleton components
|
||||
const SkeletonTable = ({ rows = 12 }) => (
|
||||
<div className="h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead><Skeleton className="h-4 w-48 bg-muted rounded-sm" /></TableHead>
|
||||
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted rounded-sm" /></TableHead>
|
||||
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted rounded-sm" /></TableHead>
|
||||
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted rounded-sm" /></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(rows)].map((_, i) => (
|
||||
<TableRow key={i} className="dark:border-gray-800">
|
||||
<TableCell className="py-3"><Skeleton className="h-4 w-64 bg-muted rounded-sm" /></TableCell>
|
||||
<TableCell className="text-right py-3"><Skeleton className="h-4 w-12 ml-auto bg-muted rounded-sm" /></TableCell>
|
||||
<TableCell className="text-right py-3"><Skeleton className="h-4 w-12 ml-auto bg-muted rounded-sm" /></TableCell>
|
||||
<TableCell className="text-right py-3"><Skeleton className="h-4 w-16 ml-auto bg-muted rounded-sm" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SkeletonPieChart = () => (
|
||||
<div className="h-60 relative">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-40 h-40 rounded-full bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 flex gap-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Skeleton className="h-3 w-3 rounded-full bg-muted" />
|
||||
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SkeletonTabs = () => (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2 mb-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-24 bg-muted rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const UserBehaviorDashboard = () => {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState("30");
|
||||
|
||||
const processPageData = (data) => {
|
||||
if (!data?.rows) {
|
||||
console.log("No rows in page data");
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.rows.map((row) => ({
|
||||
path: row.dimensionValues[0].value || "Unknown",
|
||||
pageViews: parseInt(row.metricValues[0].value || 0),
|
||||
avgSessionDuration: parseFloat(row.metricValues[1].value || 0),
|
||||
bounceRate: parseFloat(row.metricValues[2].value || 0) * 100,
|
||||
engagedSessions: parseInt(row.metricValues[3].value || 0),
|
||||
}));
|
||||
};
|
||||
|
||||
const processDeviceData = (data) => {
|
||||
if (!data?.rows) {
|
||||
console.log("No rows in device data");
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.rows
|
||||
.filter((row) => {
|
||||
const device = (row.dimensionValues[0].value || "").toLowerCase();
|
||||
return ["desktop", "mobile", "tablet"].includes(device);
|
||||
})
|
||||
.map((row) => {
|
||||
const device = row.dimensionValues[0].value || "Unknown";
|
||||
return {
|
||||
device: device.charAt(0).toUpperCase() + device.slice(1).toLowerCase(),
|
||||
pageViews: parseInt(row.metricValues[0].value || 0),
|
||||
sessions: parseInt(row.metricValues[1].value || 0),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.pageViews - a.pageViews);
|
||||
};
|
||||
|
||||
const processSourceData = (data) => {
|
||||
if (!data?.rows) {
|
||||
console.log("No rows in source data");
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.rows.map((row) => ({
|
||||
source: row.dimensionValues[0].value || "Unknown",
|
||||
sessions: parseInt(row.metricValues[0].value || 0),
|
||||
conversions: parseInt(row.metricValues[1].value || 0),
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/dashboard-analytics/user-behavior?timeRange=${timeRange}`,
|
||||
{
|
||||
credentials: "include",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch user behavior");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log("Raw user behavior response:", result);
|
||||
|
||||
if (!result?.success) {
|
||||
throw new Error("Invalid response structure");
|
||||
}
|
||||
|
||||
// Handle both data structures
|
||||
const rawData = result.data?.data || result.data;
|
||||
|
||||
// Try to access the data differently based on the structure
|
||||
const pageResponse = rawData?.pageResponse || rawData?.reports?.[0];
|
||||
const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1];
|
||||
const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2];
|
||||
|
||||
console.log("Extracted responses:", {
|
||||
pageResponse,
|
||||
deviceResponse,
|
||||
sourceResponse,
|
||||
});
|
||||
|
||||
const processed = {
|
||||
success: true,
|
||||
data: {
|
||||
pageData: {
|
||||
pageData: processPageData(pageResponse),
|
||||
deviceData: processDeviceData(deviceResponse),
|
||||
},
|
||||
sourceData: processSourceData(sourceResponse),
|
||||
},
|
||||
};
|
||||
|
||||
console.log("Final processed data:", processed);
|
||||
setData(processed);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch behavior data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [timeRange]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
|
||||
<CardHeader className="p-6 pb-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
User Behavior Analysis
|
||||
</CardTitle>
|
||||
<Skeleton className="h-9 w-36 bg-muted rounded-sm" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 pt-0">
|
||||
<Tabs defaultValue="pages" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="pages" disabled>Top Pages</TabsTrigger>
|
||||
<TabsTrigger value="sources" disabled>Traffic Sources</TabsTrigger>
|
||||
<TabsTrigger value="devices" disabled>Device Usage</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pages" className="mt-4 space-y-2">
|
||||
<SkeletonTable rows={15} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sources" className="mt-4 space-y-2">
|
||||
<SkeletonTable rows={12} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="devices" className="mt-4 space-y-2">
|
||||
<SkeletonPieChart />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
desktop: "#8b5cf6", // Purple
|
||||
mobile: "#10b981", // Green
|
||||
tablet: "#f59e0b", // Yellow
|
||||
};
|
||||
|
||||
const deviceData = data?.data?.pageData?.deviceData || [];
|
||||
const totalViews = deviceData.reduce((sum, item) => sum + item.pageViews, 0);
|
||||
const totalSessions = deviceData.reduce(
|
||||
(sum, item) => sum + item.sessions,
|
||||
0
|
||||
);
|
||||
|
||||
const CustomTooltip = ({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
const percentage = ((data.pageViews / totalViews) * 100).toFixed(1);
|
||||
const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(1);
|
||||
return (
|
||||
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border border-border">
|
||||
<CardContent className="p-0 space-y-2">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{data.device}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.pageViews.toLocaleString()} views ({percentage}%)
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.sessions.toLocaleString()} sessions ({sessionPercentage}%)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
|
||||
<CardHeader className="p-6 pb-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
User Behavior Analysis
|
||||
</CardTitle>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-36 h-9">
|
||||
<SelectValue>
|
||||
{timeRange === "7" && "Last 7 days"}
|
||||
{timeRange === "14" && "Last 14 days"}
|
||||
{timeRange === "30" && "Last 30 days"}
|
||||
{timeRange === "90" && "Last 90 days"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="14">Last 14 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 pt-0">
|
||||
<Tabs defaultValue="pages" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="pages">Top Pages</TabsTrigger>
|
||||
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
|
||||
<TabsTrigger value="devices">Device Usage</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="pages"
|
||||
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-foreground">Page Path</TableHead>
|
||||
<TableHead className="text-right text-foreground">Views</TableHead>
|
||||
<TableHead className="text-right text-foreground">Bounce Rate</TableHead>
|
||||
<TableHead className="text-right text-foreground">Avg. Duration</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.data?.pageData?.pageData.map((page, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800">
|
||||
<TableCell className="font-medium text-foreground">
|
||||
{page.path}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{page.pageViews.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{page.bounceRate.toFixed(1)}%
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{formatDuration(page.avgSessionDuration)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="sources"
|
||||
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-foreground w-[35%] min-w-[120px]">Source</TableHead>
|
||||
<TableHead className="text-right text-foreground w-[20%] min-w-[80px]">Sessions</TableHead>
|
||||
<TableHead className="text-right text-foreground w-[20%] min-w-[80px]">Conv.</TableHead>
|
||||
<TableHead className="text-right text-foreground w-[25%] min-w-[80px]">Conv. Rate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.data?.sourceData?.map((source, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800">
|
||||
<TableCell className="font-medium text-foreground break-words max-w-[160px]">
|
||||
{source.source}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
|
||||
{source.sessions.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
|
||||
{source.conversions.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
|
||||
{((source.conversions / source.sessions) * 100).toFixed(1)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="devices"
|
||||
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
|
||||
>
|
||||
<div className="h-60 bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={deviceData}
|
||||
dataKey="pageViews"
|
||||
nameKey="device"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
labelLine={false}
|
||||
label={({ name, percent }) =>
|
||||
`${name} ${(percent * 100).toFixed(1)}%`
|
||||
}
|
||||
>
|
||||
{deviceData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={COLORS[entry.device.toLowerCase()]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserBehaviorDashboard;
|
||||
26
inventory/src/components/dashboard/theme/ModeToggle.jsx
Normal file
26
inventory/src/components/dashboard/theme/ModeToggle.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "@/components/dashboard/theme/ThemeProvider"
|
||||
import { Button } from "@/components/dashboard/ui/button"
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||
className="w-9 h-9 rounded-md border-none bg-transparent hover:bg-transparent"
|
||||
>
|
||||
<div className="relative w-5 h-5">
|
||||
<Sun
|
||||
className="absolute inset-0 h-full w-full transition-all duration-300 text-yellow-500 dark:rotate-0 dark:scale-0 dark:opacity-0 rotate-0 scale-100 opacity-100"
|
||||
/>
|
||||
<Moon
|
||||
className="absolute inset-0 h-full w-full transition-all duration-300 text-slate-900 dark:text-slate-200 rotate-90 scale-0 opacity-0 dark:rotate-0 dark:scale-100 dark:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
44
inventory/src/components/dashboard/theme/ThemeProvider.jsx
Normal file
44
inventory/src/components/dashboard/theme/ThemeProvider.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
import { useTheme as useNextTheme } from "next-themes"
|
||||
|
||||
const ThemeProviderContext = createContext({
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
toggleTheme: () => null,
|
||||
})
|
||||
|
||||
// Wrapper to make dashboard components compatible with next-themes
|
||||
export function ThemeProvider({ children }) {
|
||||
const { theme: nextTheme, setTheme: setNextTheme, systemTheme: nextSystemTheme } = useNextTheme()
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (nextTheme === 'system') {
|
||||
const newTheme = nextSystemTheme === 'dark' ? 'light' : 'dark'
|
||||
setNextTheme(newTheme)
|
||||
} else {
|
||||
const newTheme = nextTheme === 'light' ? 'dark' : 'light'
|
||||
setNextTheme(newTheme)
|
||||
}
|
||||
}
|
||||
|
||||
const value = {
|
||||
theme: nextTheme || 'system',
|
||||
systemTheme: nextSystemTheme || 'light',
|
||||
setTheme: setNextTheme,
|
||||
toggleTheme,
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
47
inventory/src/components/dashboard/ui/alert.jsx
Normal file
47
inventory/src/components/dashboard/ui/alert.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props} />
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props} />
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
34
inventory/src/components/dashboard/ui/badge.jsx
Normal file
34
inventory/src/components/dashboard/ui/badge.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}) {
|
||||
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
48
inventory/src/components/dashboard/ui/button.jsx
Normal file
48
inventory/src/components/dashboard/ui/button.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
(<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
67
inventory/src/components/dashboard/ui/calendar.jsx
Normal file
67
inventory/src/components/dashboard/ui/calendar.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/dashboard/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
(<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: cn(
|
||||
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||
props.mode === "range"
|
||||
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||
: "[&:has([aria-selected])]:rounded-md"
|
||||
),
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
day_range_start: "day-range-start",
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
66
inventory/src/components/dashboard/ui/calendaredit.jsx
Normal file
66
inventory/src/components/dashboard/ui/calendaredit.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/dashboard/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-2", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-2 sm:space-x-2 sm:space-y-0",
|
||||
month: "w-full",
|
||||
caption: "flex justify-center relative items-center",
|
||||
caption_label: "text-lg font-medium", // Reduced from text-4xl
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "ghost", size: "sm"}), // Changed from lg to sm
|
||||
"h-6 w-6" // Reduced from h-12 w-18
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell: "text-muted-foreground rounded-md w-6 font-normal text-[0.7rem] w-full", // Reduced sizes
|
||||
row: "flex w-full mt-1", // Reduced margin
|
||||
cell: cn(
|
||||
"w-full relative p-0 text-center text-xs focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||
props.mode === "range"
|
||||
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||
: "[&:has([aria-selected])]:rounded-md"
|
||||
),
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-6 w-6 p-0 font-normal text-xs aria-selected:opacity-100" // Reduced from h-12 w-12 and text-lg
|
||||
),
|
||||
day_range_start: "day-range-start",
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground/50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Calendar.displayName = "Calendar"
|
||||
export { Calendar }
|
||||
50
inventory/src/components/dashboard/ui/card.jsx
Normal file
50
inventory/src/components/dashboard/ui/card.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||
{...props} />
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
308
inventory/src/components/dashboard/ui/chart.jsx
Normal file
308
inventory/src/components/dashboard/ui/chart.jsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = {
|
||||
light: "",
|
||||
dark: ".dark"
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
(<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>)
|
||||
);
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
const ChartStyle = ({
|
||||
id,
|
||||
config
|
||||
}) => {
|
||||
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
(<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`)
|
||||
.join("\n"),
|
||||
}} />)
|
||||
);
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef((
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
(<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>)
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
(<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
})}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor
|
||||
}
|
||||
} />
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>)
|
||||
);
|
||||
})
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef((
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
(<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}} />
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>)
|
||||
);
|
||||
})}
|
||||
</div>)
|
||||
);
|
||||
})
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config,
|
||||
payload,
|
||||
key
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key]
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key]
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
22
inventory/src/components/dashboard/ui/checkbox.jsx
Normal file
22
inventory/src/components/dashboard/ui/checkbox.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
9
inventory/src/components/dashboard/ui/collapsible.jsx
Normal file
9
inventory/src/components/dashboard/ui/collapsible.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
94
inventory/src/components/dashboard/ui/dialog.jsx
Normal file
94
inventory/src/components/dashboard/ui/dialog.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props} />
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
156
inventory/src/components/dashboard/ui/dropdown-menu.jsx
Normal file
156
inventory/src/components/dashboard/ui/dropdown-menu.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
(<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
53
inventory/src/components/dashboard/ui/input-otp.jsx
Normal file
53
inventory/src/components/dashboard/ui/input-otp.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { Minus } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const InputOTP = React.forwardRef(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props} />
|
||||
))
|
||||
InputOTP.displayName = "InputOTP"
|
||||
|
||||
const InputOTPGroup = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
))
|
||||
InputOTPGroup.displayName = "InputOTPGroup"
|
||||
|
||||
const InputOTPSlot = React.forwardRef(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-1 ring-ring",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>)
|
||||
);
|
||||
})
|
||||
InputOTPSlot.displayName = "InputOTPSlot"
|
||||
|
||||
const InputOTPSeparator = React.forwardRef(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus />
|
||||
</div>
|
||||
))
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator"
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
19
inventory/src/components/dashboard/ui/input.jsx
Normal file
19
inventory/src/components/dashboard/ui/input.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
(<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
16
inventory/src/components/dashboard/ui/label.jsx
Normal file
16
inventory/src/components/dashboard/ui/label.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
27
inventory/src/components/dashboard/ui/popover.jsx
Normal file
27
inventory/src/components/dashboard/ui/popover.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
23
inventory/src/components/dashboard/ui/progress.jsx
Normal file
23
inventory/src/components/dashboard/ui/progress.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
38
inventory/src/components/dashboard/ui/scroll-area.jsx
Normal file
38
inventory/src/components/dashboard/ui/scroll-area.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
119
inventory/src/components/dashboard/ui/select.jsx
Normal file
119
inventory/src/components/dashboard/ui/select.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn("p-1", position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props} />
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props} />
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
23
inventory/src/components/dashboard/ui/separator.jsx
Normal file
23
inventory/src/components/dashboard/ui/separator.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef((
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
108
inventory/src/components/dashboard/ui/sheet.jsx
Normal file
108
inventory/src/components/dashboard/ui/sheet.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref} />
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
<SheetPrimitive.Close
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
|
||||
{...props} />
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props} />
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
14
inventory/src/components/dashboard/ui/skeleton.jsx
Normal file
14
inventory/src/components/dashboard/ui/skeleton.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
(<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
86
inventory/src/components/dashboard/ui/table.jsx
Normal file
86
inventory/src/components/dashboard/ui/table.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props} />
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
41
inventory/src/components/dashboard/ui/tabs.jsx
Normal file
41
inventory/src/components/dashboard/ui/tabs.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
86
inventory/src/components/dashboard/ui/toast.jsx
Normal file
86
inventory/src/components/dashboard/ui/toast.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
(<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props} />
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
export { ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction };
|
||||
35
inventory/src/components/dashboard/ui/toaster.jsx
Normal file
35
inventory/src/components/dashboard/ui/toaster.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/dashboard/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
(<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
(<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>)
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>)
|
||||
);
|
||||
}
|
||||
43
inventory/src/components/dashboard/ui/toggle-group.jsx
Normal file
43
inventory/src/components/dashboard/ui/toggle-group.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/dashboard/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const ToggleGroup = React.forwardRef(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
|
||||
const ToggleGroupItem = React.forwardRef(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
(<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}), className)}
|
||||
{...props}>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>)
|
||||
);
|
||||
})
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
40
inventory/src/components/dashboard/ui/toggle.jsx
Normal file
40
inventory/src/components/dashboard/ui/toggle.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props} />
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
26
inventory/src/components/dashboard/ui/tooltip.jsx
Normal file
26
inventory/src/components/dashboard/ui/tooltip.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
ClipboardList,
|
||||
LogOut,
|
||||
Tags,
|
||||
Plus,
|
||||
PackagePlus,
|
||||
ShoppingBag,
|
||||
Truck,
|
||||
MessageCircle,
|
||||
LayoutDashboard,
|
||||
} from "lucide-react";
|
||||
import { IconCrystalBall } from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarFooter,
|
||||
SidebarMenu,
|
||||
@@ -28,12 +30,21 @@ import {
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom";
|
||||
import { Protected } from "@/components/auth/Protected";
|
||||
|
||||
const items = [
|
||||
const dashboardItems = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
url: "/dashboard",
|
||||
permission: "access:dashboard"
|
||||
}
|
||||
];
|
||||
|
||||
const inventoryItems = [
|
||||
{
|
||||
title: "Overview",
|
||||
icon: Home,
|
||||
url: "/",
|
||||
permission: "access:dashboard"
|
||||
permission: "access:overview"
|
||||
},
|
||||
{
|
||||
title: "Products",
|
||||
@@ -76,15 +87,21 @@ const items = [
|
||||
icon: IconCrystalBall,
|
||||
url: "/forecasting",
|
||||
permission: "access:forecasting"
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
const productSetupItems = [
|
||||
{
|
||||
title: "Create Products",
|
||||
icon: Plus,
|
||||
icon: PackagePlus,
|
||||
url: "/import",
|
||||
permission: "access:import"
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
const chatItems = [
|
||||
{
|
||||
title: "Chat",
|
||||
title: "Chat Archive",
|
||||
icon: MessageCircle,
|
||||
url: "/chat",
|
||||
permission: "access:chat"
|
||||
@@ -102,6 +119,46 @@ export function AppSidebar() {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const renderMenuItems = (items: typeof inventoryItems) => {
|
||||
return items.map((item) => {
|
||||
const isActive =
|
||||
location.pathname === item.url ||
|
||||
(item.url !== "/" && item.url !== "#" && location.pathname.startsWith(item.url));
|
||||
return (
|
||||
<Protected
|
||||
key={item.title}
|
||||
permission={item.permission}
|
||||
fallback={null}
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild={item.url !== "#"}
|
||||
tooltip={item.title}
|
||||
isActive={isActive}
|
||||
disabled={item.url === "#"}
|
||||
>
|
||||
{item.url === "#" ? (
|
||||
<div>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Link to={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</Protected>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="sidebar">
|
||||
<SidebarHeader>
|
||||
@@ -122,42 +179,53 @@ export function AppSidebar() {
|
||||
</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
<SidebarContent>
|
||||
{/* Dashboard Section */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Dashboard</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
location.pathname === item.url ||
|
||||
(item.url !== "/" && location.pathname.startsWith(item.url));
|
||||
return (
|
||||
<Protected
|
||||
key={item.title}
|
||||
permission={item.permission}
|
||||
fallback={null}
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={isActive}
|
||||
>
|
||||
<Link to={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</Protected>
|
||||
);
|
||||
})}
|
||||
{renderMenuItems(dashboardItems)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
|
||||
{/* Inventory Section */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Inventory</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{renderMenuItems(inventoryItems)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
|
||||
{/* Product Setup Section */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Product Setup</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{renderMenuItems(productSetupItems)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
|
||||
{/* Chat Section */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Chat</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{renderMenuItems(chatItems)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarSeparator />
|
||||
|
||||
{/* Settings Section */}
|
||||
<SidebarGroup>
|
||||
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<Protected
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Calendar, Users, DollarSign, Tag, Package, Clock, AlertTriangle } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { ProductMetric, ProductStatus } from "@/types/products";
|
||||
import {
|
||||
getStatusBadge,
|
||||
@@ -140,18 +140,6 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
return statusMap[status] || 'Unknown';
|
||||
};
|
||||
|
||||
// Get receiving status names
|
||||
const getReceivingStatusName = (status: number): string => {
|
||||
const statusMap: {[key: number]: string} = {
|
||||
0: 'Canceled',
|
||||
1: 'Created',
|
||||
30: 'Partial Received',
|
||||
40: 'Fully Received',
|
||||
50: 'Paid'
|
||||
};
|
||||
return statusMap[status] || 'Unknown';
|
||||
};
|
||||
|
||||
// Get status badge color class
|
||||
const getStatusBadgeClass = (status: number): string => {
|
||||
if (status === 0) return "bg-destructive text-destructive-foreground"; // Canceled
|
||||
|
||||
@@ -74,10 +74,12 @@ const BASE_FILTER_OPTIONS: FilterOption[] = [
|
||||
{ id: 'isReplenishable', label: 'Replenishable', type: 'boolean', group: 'Basic Info', operators: BOOLEAN_OPERATORS },
|
||||
{ id: 'abcClass', label: 'ABC Class', type: 'select', group: 'Basic Info', operators: SELECT_OPERATORS, options: [] },
|
||||
{ id: 'status', label: 'Status', type: 'select', group: 'Basic Info', operators: SELECT_OPERATORS, options: [
|
||||
{ value: 'in_stock', label: 'In Stock' },
|
||||
{ value: 'low_stock', label: 'Low Stock' },
|
||||
{ value: 'out_of_stock', label: 'Out of Stock' },
|
||||
{ value: 'discontinued', label: 'Discontinued' },
|
||||
{ value: 'Critical', label: 'Critical' },
|
||||
{ value: 'At Risk', label: 'At Risk' },
|
||||
{ value: 'Reorder', label: 'Reorder' },
|
||||
{ value: 'Overstocked', label: 'Overstocked' },
|
||||
{ value: 'Healthy', label: 'Healthy' },
|
||||
{ value: 'New', label: 'New' },
|
||||
]},
|
||||
{ id: 'dateCreated', label: 'Created Date', type: 'date', group: 'Basic Info', operators: DATE_OPERATORS },
|
||||
|
||||
@@ -91,6 +93,9 @@ const BASE_FILTER_OPTIONS: FilterOption[] = [
|
||||
|
||||
// Physical Properties group
|
||||
{ id: 'weight', label: 'Weight', type: 'number', group: 'Physical', operators: NUMBER_OPERATORS },
|
||||
{ id: 'length', label: 'Length', type: 'number', group: 'Physical', operators: NUMBER_OPERATORS },
|
||||
{ id: 'width', label: 'Width', type: 'number', group: 'Physical', operators: NUMBER_OPERATORS },
|
||||
{ id: 'height', label: 'Height', type: 'number', group: 'Physical', operators: NUMBER_OPERATORS },
|
||||
{ id: 'dimensions', label: 'Dimensions', type: 'text', group: 'Physical', operators: STRING_OPERATORS },
|
||||
|
||||
// Customer Engagement group
|
||||
@@ -99,18 +104,24 @@ const BASE_FILTER_OPTIONS: FilterOption[] = [
|
||||
{ id: 'baskets', label: 'Basket Adds', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
|
||||
{ id: 'notifies', label: 'Stock Alerts', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
|
||||
|
||||
// Stock group
|
||||
{ id: 'currentStock', label: 'Current Stock', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'preorderCount', label: 'Preorders', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'notionsInvCount', label: 'Notions Inventory', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'onOrderQty', label: 'On Order', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'configSafetyStock', label: 'Safety Stock', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'replenishmentUnits', label: 'Replenish Qty', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'toOrderUnits', label: 'To Order', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'stockCoverInDays', label: 'Stock Cover (Days)', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'sellsOutInDays', label: 'Sells Out In (Days)', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'isOldStock', label: 'Old Stock', type: 'boolean', group: 'Stock', operators: BOOLEAN_OPERATORS },
|
||||
{ id: 'overstockedUnits', label: 'Overstock Qty', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
// Inventory & Stock group
|
||||
{ id: 'currentStock', label: 'Current Stock', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'preorderCount', label: 'Preorders', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'notionsInvCount', label: 'Notions Inventory', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'onOrderQty', label: 'On Order', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'configSafetyStock', label: 'Safety Stock', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'replenishmentUnits', label: 'Replenish Qty', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'toOrderUnits', label: 'To Order', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'stockCoverInDays', label: 'Stock Cover (Days)', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'sellsOutInDays', label: 'Sells Out In (Days)', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'isOldStock', label: 'Old Stock', type: 'boolean', group: 'Inventory', operators: BOOLEAN_OPERATORS },
|
||||
{ id: 'overstockedUnits', label: 'Overstock Qty', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'stockoutDays30d', label: 'Stockout Days (30d)', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'stockoutRate30d', label: 'Stockout Rate %', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'avgStockUnits30d', label: 'Avg Stock Units (30d)', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'receivedQty30d', label: 'Received Qty (30d)', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'poCoverInDays', label: 'PO Cover (Days)', type: 'number', group: 'Inventory', operators: NUMBER_OPERATORS },
|
||||
{ id: 'earliestExpectedDate', label: 'Expected Date', type: 'date', group: 'Inventory', operators: DATE_OPERATORS },
|
||||
|
||||
// Pricing Group
|
||||
{ id: "currentPrice", label: "Current Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
@@ -119,6 +130,9 @@ const BASE_FILTER_OPTIONS: FilterOption[] = [
|
||||
{ id: "currentLandingCostPrice", label: "Landing Cost", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
|
||||
// Valuation Group
|
||||
{ id: "currentStockCost", label: "Current Stock Cost", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "currentStockRetail", label: "Current Stock Retail", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "currentStockGross", label: "Current Stock Gross", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "avgStockCost30d", label: "Avg Stock Cost (30d)", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "avgStockRetail30d", label: "Avg Stock Retail (30d)", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "avgStockGross30d", label: "Avg Stock Gross (30d)", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
@@ -132,6 +146,8 @@ const BASE_FILTER_OPTIONS: FilterOption[] = [
|
||||
{ id: "overstockedRetail", label: "Overstock Retail", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
|
||||
// Sales Metrics Group
|
||||
{ id: "salesVelocityDaily", label: "Daily Velocity", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "yesterdaySales", label: "Yesterday Sales", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "sales7d", label: "Sales (7d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "revenue7d", label: "Revenue (7d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "sales14d", label: "Sales (14d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
@@ -140,9 +156,7 @@ const BASE_FILTER_OPTIONS: FilterOption[] = [
|
||||
{ id: "revenue30d", label: "Revenue (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "sales365d", label: "Sales (365d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "revenue365d", label: "Revenue (365d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "salesVelocityDaily", label: "Daily Velocity", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "dateLastSold", label: "Date Last Sold", type: "date", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "yesterdaySales", label: "Sales (Yesterday)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "avgSalesPerDay30d", label: "Avg Sales/Day (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "avgSalesPerMonth30d", label: "Avg Sales/Month (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "returnsUnits30d", label: "Returns Units (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
@@ -188,20 +202,45 @@ const BASE_FILTER_OPTIONS: FilterOption[] = [
|
||||
{ id: "replenishmentNeededRaw", label: "Replenishment Needed Raw", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "forecastLostSalesUnits", label: "Forecast Lost Sales Units", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "forecastLostRevenue", label: "Forecast Lost Revenue", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "stockoutDays30d", label: "Stockout Days (30d)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "stockoutRate30d", label: "Stockout Rate %", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "avgStockUnits30d", label: "Avg Stock Units (30d)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "receivedQty30d", label: "Received Qty (30d)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "poCoverInDays", label: "PO Cover (Days)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
|
||||
// Dates & Timing Group
|
||||
{ id: "dateFirstReceived", label: "First Received", type: "date", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "dateLastReceived", label: "Last Received", type: "date", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "dateFirstSold", label: "First Sold", type: "date", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "ageDays", label: "Age (Days)", type: "number", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "avgLeadTimeDays", label: "Avg Lead Time", type: "number", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "replenishDate", label: "Replenish Date", type: "date", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "planningPeriodDays", label: "Planning Period (Days)", type: "number", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
|
||||
// Lead Time & Replenishment
|
||||
{ id: "configLeadTime", label: "Config Lead Time", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "configDaysOfStock", label: "Config Days of Stock", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "avgLeadTimeDays", label: "Avg Lead Time", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "earliestExpectedDate", label: "Next Arrival Date", type: "date", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "dateLastReceived", label: "Date Last Received", type: "date", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "dateFirstReceived", label: "First Received", type: "date", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "dateFirstSold", label: "First Sold", type: "date", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
|
||||
// Growth Analysis Group
|
||||
{ id: "salesGrowth30dVsPrev", label: "Sales Growth % (30d vs Prev)", type: "number", group: "Growth Analysis", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "revenueGrowth30dVsPrev", label: "Revenue Growth % (30d vs Prev)", type: "number", group: "Growth Analysis", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "salesGrowthYoy", label: "Sales Growth % YoY", type: "number", group: "Growth Analysis", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "revenueGrowthYoy", label: "Revenue Growth % YoY", type: "number", group: "Growth Analysis", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
|
||||
// Demand Variability Group
|
||||
{ id: "salesVariance30d", label: "Sales Variance (30d)", type: "number", group: "Demand Variability", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "salesStdDev30d", label: "Sales Std Dev (30d)", type: "number", group: "Demand Variability", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "salesCv30d", label: "Sales CV % (30d)", type: "number", group: "Demand Variability", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "demandPattern", label: "Demand Pattern", type: "text", group: "Demand Variability", operators: STRING_OPERATORS },
|
||||
|
||||
// Service Level Group
|
||||
{ id: "fillRate30d", label: "Fill Rate % (30d)", type: "number", group: "Service Level", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "stockoutIncidents30d", label: "Stockout Incidents (30d)", type: "number", group: "Service Level", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "serviceLevel30d", label: "Service Level % (30d)", type: "number", group: "Service Level", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "lostSalesIncidents30d", label: "Lost Sales Incidents (30d)", type: "number", group: "Service Level", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
|
||||
// Seasonality Group
|
||||
{ id: "seasonalityIndex", label: "Seasonality Index", type: "number", group: "Seasonality", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "seasonalPattern", label: "Seasonal Pattern", type: "text", group: "Seasonality", operators: STRING_OPERATORS },
|
||||
{ id: "peakSeason", label: "Peak Season", type: "text", group: "Seasonality", operators: STRING_OPERATORS },
|
||||
|
||||
// Data Quality Group
|
||||
{ id: "lifetimeRevenueQuality", label: "Lifetime Revenue Quality", type: "text", group: "Data Quality", operators: STRING_OPERATORS },
|
||||
];
|
||||
|
||||
interface ProductFiltersProps {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
@@ -26,7 +25,7 @@ import {
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { ProductMetric, ProductMetricColumnKey } from "@/types/products";
|
||||
import { ProductMetric, ProductMetricColumnKey, ProductStatus } from "@/types/products";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getStatusBadge } from "@/utils/productUtils";
|
||||
|
||||
@@ -42,15 +41,15 @@ interface ColumnDef {
|
||||
|
||||
interface ProductTableProps {
|
||||
products: ProductMetric[];
|
||||
onViewProduct: (id: number) => void;
|
||||
isLoading?: boolean;
|
||||
onColumnOrderChange?: (newOrder: ProductMetricColumnKey[]) => void;
|
||||
visibleColumns?: Set<ProductMetricColumnKey>;
|
||||
columnOrder?: ProductMetricColumnKey[];
|
||||
onSort: (column: ProductMetricColumnKey) => void;
|
||||
sortColumn: ProductMetricColumnKey;
|
||||
sortDirection: 'asc' | 'desc';
|
||||
visibleColumns: Set<ProductMetricColumnKey>;
|
||||
columnDefs: ColumnDef[];
|
||||
columnOrder: ProductMetricColumnKey[];
|
||||
onColumnOrderChange?: (columns: ProductMetricColumnKey[]) => void;
|
||||
onRowClick?: (product: ProductMetric) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface SortableHeaderProps {
|
||||
@@ -121,18 +120,17 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
||||
}
|
||||
|
||||
export function ProductTable({
|
||||
products,
|
||||
products = [],
|
||||
onViewProduct,
|
||||
isLoading = false,
|
||||
onColumnOrderChange,
|
||||
visibleColumns = new Set<ProductMetricColumnKey>(),
|
||||
columnOrder = [],
|
||||
onSort,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
visibleColumns,
|
||||
columnDefs,
|
||||
columnOrder = columnDefs.map(col => col.key),
|
||||
onColumnOrderChange,
|
||||
onRowClick,
|
||||
isLoading = false,
|
||||
}: ProductTableProps) {
|
||||
const [activeId, setActiveId] = React.useState<ProductMetricColumnKey | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
@@ -147,13 +145,12 @@ export function ProductTable({
|
||||
return columnOrder.filter(col => visibleColumns.has(col));
|
||||
}, [columnOrder, visibleColumns]);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as ProductMetricColumnKey);
|
||||
const handleDragStart = () => {
|
||||
// No need to set activeId as it's not used in the new implementation
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (over && active.id !== over.id && onColumnOrderChange) {
|
||||
const oldIndex = orderedVisibleColumns.indexOf(active.id as ProductMetricColumnKey);
|
||||
@@ -166,19 +163,21 @@ export function ProductTable({
|
||||
}
|
||||
};
|
||||
|
||||
const formatColumnValue = (product: ProductMetric, columnKey: ProductMetricColumnKey) => {
|
||||
const columnDef = columnDefs.find(def => def.key === columnKey);
|
||||
const formatColumnValue = (product: ProductMetric, columnKey: ProductMetricColumnKey): React.ReactNode => {
|
||||
const value = product[columnKey as keyof ProductMetric];
|
||||
|
||||
if (columnKey === 'status') {
|
||||
return <div dangerouslySetInnerHTML={{ __html: getStatusBadge(product.status || 'Unknown') }} />;
|
||||
}
|
||||
|
||||
const columnDef = columnDefs.find(col => col.key === columnKey);
|
||||
|
||||
// Use the format function from column definition if available
|
||||
if (columnDef?.format) {
|
||||
return columnDef.format(value, product);
|
||||
}
|
||||
|
||||
// Default formatting for common types if no formatter provided
|
||||
|
||||
// Special handling for status
|
||||
if (columnKey === 'status') {
|
||||
return <div dangerouslySetInnerHTML={{ __html: getStatusBadge(value as ProductStatus) }} />;
|
||||
}
|
||||
|
||||
// Special handling for boolean values
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
@@ -207,7 +206,7 @@ export function ProductTable({
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => setActiveId(null)}
|
||||
onDragCancel={() => {}}
|
||||
>
|
||||
<div className="border rounded-md relative">
|
||||
{isLoading && (
|
||||
@@ -250,7 +249,7 @@ export function ProductTable({
|
||||
products.map((product) => (
|
||||
<TableRow
|
||||
key={product.pid}
|
||||
onClick={() => onRowClick?.(product)}
|
||||
onClick={() => onViewProduct(product.pid)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
data-state={isLoading ? 'loading' : undefined}
|
||||
>
|
||||
@@ -308,4 +307,4 @@ export function ProductTable({
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ProductFilterOptions, ProductMetric } from "@/types/products";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ProductFilterOptions, ProductMetric, ProductMetricColumnKey } from "@/types/products";
|
||||
import { ProductTable } from "./ProductTable";
|
||||
import { ProductFilters } from "./ProductFilters";
|
||||
import { ProductDetail } from "./ProductDetail";
|
||||
@@ -89,16 +89,6 @@ export function Products() {
|
||||
},
|
||||
});
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
searchParams.set("page", page.toString());
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
searchParams.set("pageSize", size.toString());
|
||||
searchParams.set("page", "1"); // Reset to first page when changing page size
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const handleSortChange = (field: string, direction: "asc" | "desc") => {
|
||||
searchParams.set("sortBy", field);
|
||||
@@ -106,36 +96,10 @@ export function Products() {
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const handleFilterChange = (type: string, value: string) => {
|
||||
if (type && value) {
|
||||
searchParams.set("filterType", type);
|
||||
searchParams.set("filterValue", value);
|
||||
} else {
|
||||
searchParams.delete("filterType");
|
||||
searchParams.delete("filterValue");
|
||||
}
|
||||
searchParams.set("page", "1"); // Reset to first page when applying filters
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const handleStatusFilterChange = (status: string) => {
|
||||
if (status) {
|
||||
searchParams.set("status", status);
|
||||
} else {
|
||||
searchParams.delete("status");
|
||||
}
|
||||
searchParams.set("page", "1"); // Reset to first page when changing status filter
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const handleSearchChange = (query: string) => {
|
||||
if (query) {
|
||||
searchParams.set("search", query);
|
||||
} else {
|
||||
searchParams.delete("search");
|
||||
}
|
||||
searchParams.set("page", "1"); // Reset to first page when searching
|
||||
setSearchParams(searchParams);
|
||||
const handleSort = (column: ProductMetricColumnKey) => {
|
||||
// Toggle sort direction if same column, otherwise default to asc
|
||||
const newDirection = sortBy === column && sortDirection === "asc" ? "desc" : "asc";
|
||||
handleSortChange(column, newDirection as "asc" | "desc");
|
||||
};
|
||||
|
||||
const handleViewProduct = (id: number) => {
|
||||
@@ -209,7 +173,10 @@ export function Products() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center min-h-[300px]">
|
||||
<Spinner size="lg" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span>Loading products...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-destructive/10 p-4 rounded-lg text-center text-destructive border border-destructive">
|
||||
@@ -218,15 +185,19 @@ export function Products() {
|
||||
) : (
|
||||
<ProductTable
|
||||
products={data?.products || []}
|
||||
total={data?.total || 0}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
sortBy={sortBy}
|
||||
sortDirection={sortDirection as "asc" | "desc"}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
onSortChange={handleSortChange}
|
||||
onViewProduct={handleViewProduct}
|
||||
isLoading={isLoading}
|
||||
onSort={handleSort}
|
||||
sortColumn={sortBy as ProductMetricColumnKey}
|
||||
sortDirection={sortDirection as "asc" | "desc"}
|
||||
columnDefs={[
|
||||
{ key: 'title', label: 'Name', group: 'Product' },
|
||||
{ key: 'brand', label: 'Brand', group: 'Product' },
|
||||
{ key: 'sku', label: 'SKU', group: 'Product' },
|
||||
{ key: 'currentStock', label: 'Stock', group: 'Inventory' },
|
||||
{ key: 'currentPrice', label: 'Price', group: 'Pricing' },
|
||||
{ key: 'status', label: 'Status', group: 'Product' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,22 +2,20 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import config from '../../config';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search } from 'lucide-react';
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { Search } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import config from "@/config";
|
||||
|
||||
interface VendorSetting {
|
||||
vendor: string;
|
||||
@@ -35,6 +33,7 @@ export function VendorSettings() {
|
||||
const [searchInputValue, setSearchInputValue] = useState('');
|
||||
const searchQuery = useDebounce(searchInputValue, 300); // 300ms debounce
|
||||
const [pendingChanges, setPendingChanges] = useState<Record<string, boolean>>({});
|
||||
const { toast } = useToast();
|
||||
|
||||
// Use useCallback to avoid unnecessary re-renders
|
||||
const loadSettings = useCallback(async () => {
|
||||
@@ -50,11 +49,15 @@ export function VendorSettings() {
|
||||
setSettings(data.items);
|
||||
setTotalCount(data.total);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to load settings: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to load settings: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, searchQuery, pageSize]);
|
||||
}, [page, searchQuery, pageSize, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
@@ -89,12 +92,19 @@ export function VendorSettings() {
|
||||
throw new Error(data.error || 'Failed to update vendor setting');
|
||||
}
|
||||
|
||||
toast.success(`Settings updated for vendor ${vendor}`);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `Settings updated for vendor ${vendor}`,
|
||||
});
|
||||
setPendingChanges(prev => ({ ...prev, [vendor]: false }));
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update setting: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to update setting: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
}, [settings, toast]);
|
||||
|
||||
const handleResetToDefault = useCallback(async (vendor: string) => {
|
||||
try {
|
||||
@@ -108,12 +118,19 @@ export function VendorSettings() {
|
||||
throw new Error(data.error || 'Failed to reset vendor setting');
|
||||
}
|
||||
|
||||
toast.success(`Settings reset for vendor ${vendor}`);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `Settings reset for vendor ${vendor}`,
|
||||
});
|
||||
loadSettings(); // Reload settings to get defaults
|
||||
} catch (error) {
|
||||
toast.error(`Failed to reset setting: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to reset setting: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [loadSettings]);
|
||||
}, [loadSettings, toast]);
|
||||
|
||||
const totalPages = useMemo(() => Math.ceil(totalCount / pageSize), [totalCount, pageSize]);
|
||||
|
||||
@@ -261,7 +278,7 @@ export function VendorSettings() {
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={i}>
|
||||
<PaginationEllipsis />
|
||||
<span className="px-4 py-2">...</span>
|
||||
</PaginationItem>
|
||||
)
|
||||
))}
|
||||
|
||||
31
inventory/src/components/ui/page-loading.tsx
Normal file
31
inventory/src/components/ui/page-loading.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface PageLoadingProps {
|
||||
message?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const PageLoading = ({ message = 'Loading...', size = 'md' }: PageLoadingProps) => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12'
|
||||
};
|
||||
|
||||
const containerClasses = {
|
||||
sm: 'min-h-[200px]',
|
||||
md: 'min-h-[400px]',
|
||||
lg: 'min-h-[600px]'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${containerClasses[size]}`}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Loader2 className={`animate-spin text-primary ${sizeClasses[size]}`} />
|
||||
<p className="text-sm text-muted-foreground animate-pulse">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageLoading;
|
||||
15
inventory/src/config/dashboard.ts
Normal file
15
inventory/src/config/dashboard.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
const liveDashboardConfig = {
|
||||
auth: isDev ? '/dashboard-auth' : 'https://dashboard.kent.pw/auth',
|
||||
aircall: isDev ? '/api/aircall' : 'https://dashboard.kent.pw/api/aircall',
|
||||
klaviyo: isDev ? '/api/klaviyo' : 'https://dashboard.kent.pw/api/klaviyo',
|
||||
meta: isDev ? '/api/meta' : 'https://dashboard.kent.pw/api/meta',
|
||||
gorgias: isDev ? '/api/gorgias' : 'https://dashboard.kent.pw/api/gorgias',
|
||||
analytics: isDev ? '/api/dashboard-analytics' : 'https://dashboard.kent.pw/api/analytics',
|
||||
typeform: isDev ? '/api/typeform' : 'https://dashboard.kent.pw/api/typeform',
|
||||
acot: isDev ? '/api/acot' : 'https://dashboard.kent.pw/api/acot',
|
||||
clarity: isDev ? '/api/clarity' : 'https://dashboard.kent.pw/api/clarity'
|
||||
};
|
||||
|
||||
export default liveDashboardConfig;
|
||||
97
inventory/src/contexts/DashboardScrollContext.tsx
Normal file
97
inventory/src/contexts/DashboardScrollContext.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { createContext, useContext, ReactNode, useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface ScrollContextType {
|
||||
scrollToSection: (sectionId: string) => void;
|
||||
isStuck: boolean;
|
||||
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const ScrollContext = createContext<ScrollContextType | undefined>(undefined);
|
||||
|
||||
export const useScroll = () => {
|
||||
const context = useContext(ScrollContext);
|
||||
if (!context) {
|
||||
throw new Error('useScroll must be used within a ScrollProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ScrollProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ScrollProvider: React.FC<ScrollProviderProps> = ({ children }) => {
|
||||
const [isStuck, setIsStuck] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = (e: Event) => {
|
||||
const scrollTop = e.target instanceof Element ? e.target.scrollTop : 0;
|
||||
const headerHeight = 100; // Adjust as needed
|
||||
setIsStuck(scrollTop > headerHeight);
|
||||
};
|
||||
|
||||
// Try to find the scroll container
|
||||
const findScrollContainer = () => {
|
||||
// First try to find the live dashboard scroll container
|
||||
const container = document.getElementById('dashboard-scroll-container');
|
||||
if (container) {
|
||||
scrollContainerRef.current = container;
|
||||
return container;
|
||||
}
|
||||
|
||||
// Fallback to the MainLayout scroll container
|
||||
const mainLayoutContainer = document.querySelector('.overflow-auto.h-\\[calc\\(100vh-3\\.5rem\\)\\]');
|
||||
if (mainLayoutContainer) {
|
||||
scrollContainerRef.current = mainLayoutContainer as HTMLElement;
|
||||
return mainLayoutContainer;
|
||||
}
|
||||
|
||||
// Final fallback to window
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = findScrollContainer();
|
||||
|
||||
if (container) {
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
} else {
|
||||
// Fallback to window scroll
|
||||
const windowScroll = () => {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
const headerHeight = 100;
|
||||
setIsStuck(scrollTop > headerHeight);
|
||||
};
|
||||
window.addEventListener('scroll', windowScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', windowScroll);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollToSection = (sectionId: string) => {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element && scrollContainerRef.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
const elementTop = element.offsetTop;
|
||||
const containerTop = container.offsetTop;
|
||||
const scrollTop = elementTop - containerTop - 80; // 80px offset for header
|
||||
|
||||
container.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else if (element) {
|
||||
// Fallback to window scrolling
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollContext.Provider value={{ scrollToSection, isStuck, scrollContainerRef }}>
|
||||
{children}
|
||||
</ScrollContext.Provider>
|
||||
);
|
||||
};
|
||||
36
inventory/src/lib/dashboard/constants.js
Normal file
36
inventory/src/lib/dashboard/constants.js
Normal file
@@ -0,0 +1,36 @@
|
||||
export const TIME_RANGES = [
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: 'yesterday', label: 'Yesterday' },
|
||||
{ value: 'last7days', label: 'Last 7 Days' },
|
||||
{ value: 'last30days', label: 'Last 30 Days' },
|
||||
{ value: 'last90days', label: 'Last 90 Days' },
|
||||
{ value: 'thisWeek', label: 'This Week' },
|
||||
{ value: 'lastWeek', label: 'Last Week' },
|
||||
{ value: 'thisMonth', label: 'This Month' },
|
||||
{ value: 'lastMonth', label: 'Last Month' }
|
||||
];
|
||||
|
||||
export const GROUP_BY_OPTIONS = [
|
||||
{ value: 'hour', label: 'Hourly' },
|
||||
{ value: 'day', label: 'Daily' },
|
||||
{ value: 'week', label: 'Weekly' },
|
||||
{ value: 'month', label: 'Monthly' }
|
||||
];
|
||||
|
||||
// Format a date object to a datetime-local input string
|
||||
export const formatDateForInput = (date) => {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
|
||||
return new Date(d.getTime() - d.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
};
|
||||
|
||||
// Parse a datetime-local input string to a date object
|
||||
export const parseDateFromInput = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
const date = new Date(dateString);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
@@ -16,7 +16,8 @@ import { Badge } from "@/components/ui/badge";
|
||||
type BrandSortableColumns =
|
||||
| 'brandName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
|
||||
| 'currentStockCost' | 'currentStockRetail' | 'revenue_7d' | 'revenue_30d'
|
||||
| 'profit_30d' | 'sales_30d' | 'avg_margin_30d' | 'stock_turn_30d' | 'status'; // Add more as needed
|
||||
| 'profit_30d' | 'sales_30d' | 'avg_margin_30d' | 'stock_turn_30d'
|
||||
| 'salesGrowth30dVsPrev' | 'revenueGrowth30dVsPrev' | 'status';
|
||||
|
||||
interface BrandMetric {
|
||||
brand_id: string | number;
|
||||
@@ -40,6 +41,9 @@ interface BrandMetric {
|
||||
lifetime_revenue: string | number;
|
||||
avg_margin_30d: string | number | null;
|
||||
stock_turn_30d: string | number | null;
|
||||
// Growth metrics
|
||||
sales_growth_30d_vs_prev: string | number | null;
|
||||
revenue_growth_30d_vs_prev: string | number | null;
|
||||
status: string;
|
||||
brand_status: string;
|
||||
description: string;
|
||||
@@ -57,6 +61,8 @@ interface BrandMetric {
|
||||
lifetimeRevenue: string | number;
|
||||
avgMargin_30d: string | number | null;
|
||||
stockTurn_30d: string | number | null;
|
||||
salesGrowth30dVsPrev: string | number | null;
|
||||
revenueGrowth30dVsPrev: string | number | null;
|
||||
}
|
||||
|
||||
// Define response type to avoid type errors
|
||||
@@ -140,6 +146,19 @@ const formatPercentage = (value: number | string | null | undefined, digits = 1)
|
||||
return `${value.toFixed(digits)}%`;
|
||||
};
|
||||
|
||||
// Growth formatting with color coding
|
||||
const formatGrowth = (value: number | string | null | undefined, digits = 1) => {
|
||||
if (value == null) return <span className="text-muted-foreground">N/A</span>;
|
||||
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (isNaN(numValue)) return <span className="text-muted-foreground">N/A</span>;
|
||||
|
||||
const formatted = `${numValue >= 0 ? '+' : ''}${numValue.toFixed(digits)}%`;
|
||||
const colorClass = numValue >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
|
||||
return <span className={colorClass}>{formatted}</span>;
|
||||
};
|
||||
|
||||
const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
@@ -361,6 +380,8 @@ export function Brands() {
|
||||
<TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("stock_turn_30d")} className="cursor-pointer text-right">Stock Turn (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("salesGrowth30dVsPrev")} className="cursor-pointer text-right">Sales Growth</TableHead>
|
||||
<TableHead onClick={() => handleSort("revenueGrowth30dVsPrev")} className="cursor-pointer text-right">Revenue Growth</TableHead>
|
||||
<TableHead onClick={() => handleSort("status")} className="cursor-pointer text-right">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -378,17 +399,19 @@ export function Brands() {
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : listError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-destructive">
|
||||
<TableCell colSpan={12} className="text-center py-8 text-destructive">
|
||||
Error loading brands: {listError.message}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : brands.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={12} className="text-center py-8 text-muted-foreground">
|
||||
No brands found matching your criteria.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -404,6 +427,8 @@ export function Brands() {
|
||||
<TableCell className="text-right">{formatCurrency(brand.profit_30d as number)}</TableCell>
|
||||
<TableCell className="text-right">{formatPercentage(brand.avg_margin_30d as number)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(brand.stock_turn_30d, 2)}</TableCell>
|
||||
<TableCell className="text-right">{formatGrowth(brand.sales_growth_30d_vs_prev)}</TableCell>
|
||||
<TableCell className="text-right">{formatGrowth(brand.revenue_growth_30d_vs_prev)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant={getStatusVariant(brand.status)}>
|
||||
{brand.status || 'Unknown'}
|
||||
|
||||
@@ -60,6 +60,8 @@ type CategorySortableColumns =
|
||||
| "sales30d"
|
||||
| "avgMargin30d"
|
||||
| "stockTurn30d"
|
||||
| "salesGrowth30dVsPrev"
|
||||
| "revenueGrowth30dVsPrev"
|
||||
| "status";
|
||||
|
||||
interface CategoryMetric {
|
||||
@@ -88,6 +90,9 @@ interface CategoryMetric {
|
||||
lifetime_revenue: string | number;
|
||||
avg_margin_30d: string | number | null;
|
||||
stock_turn_30d: string | number | null;
|
||||
// Growth metrics
|
||||
sales_growth_30d_vs_prev: string | number | null;
|
||||
revenue_growth_30d_vs_prev: string | number | null;
|
||||
// Fields from categories table
|
||||
status: string;
|
||||
description: string;
|
||||
@@ -108,6 +113,8 @@ interface CategoryMetric {
|
||||
lifetimeRevenue: string | number;
|
||||
avgMargin_30d: string | number | null;
|
||||
stockTurn_30d: string | number | null;
|
||||
salesGrowth30dVsPrev: string | number | null;
|
||||
revenueGrowth30dVsPrev: string | number | null;
|
||||
direct_active_product_count: number;
|
||||
direct_current_stock_units: number;
|
||||
direct_stock_cost: string | number;
|
||||
@@ -147,8 +154,6 @@ interface CategoryFilters {
|
||||
showInactive: boolean; // Filter for showing categories with 0 active products
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 50; // Consistent with backend default
|
||||
|
||||
// Helper for formatting
|
||||
const formatCurrency = (
|
||||
value: number | string | null | undefined,
|
||||
@@ -208,6 +213,19 @@ const formatPercentage = (
|
||||
return `${value.toFixed(digits)}%`;
|
||||
};
|
||||
|
||||
// Growth formatting with color coding
|
||||
const formatGrowth = (value: number | string | null | undefined, digits = 1) => {
|
||||
if (value == null) return <span className="text-muted-foreground">N/A</span>;
|
||||
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (isNaN(numValue)) return <span className="text-muted-foreground">N/A</span>;
|
||||
|
||||
const formatted = `${numValue >= 0 ? '+' : ''}${numValue.toFixed(digits)}%`;
|
||||
const colorClass = numValue >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
|
||||
return <span className={colorClass}>{formatted}</span>;
|
||||
};
|
||||
|
||||
// Define interfaces for hierarchical structure
|
||||
interface CategoryWithChildren extends CategoryMetric {
|
||||
children: CategoryWithChildren[];
|
||||
@@ -221,6 +239,8 @@ interface CategoryWithChildren extends CategoryMetric {
|
||||
revenue30d: number;
|
||||
profit30d: number;
|
||||
avg_margin_30d?: number;
|
||||
sales_growth_30d_vs_prev?: number;
|
||||
revenue_growth_30d_vs_prev?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,45 +283,6 @@ const TypeBadge = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Function to find the top-level ancestor of an orphan
|
||||
const findTopLevelType = (categoryType: number): number => {
|
||||
switch (categoryType) {
|
||||
case 11: // Category belongs to section
|
||||
case 12: // Subcategory belongs to section
|
||||
case 13: // Sub-subcategory belongs to section
|
||||
return 10; // Section
|
||||
case 21: // Subtheme belongs to theme
|
||||
return 20; // Theme
|
||||
case 2: // Line belongs to company
|
||||
case 3: // Subline belongs to company
|
||||
return 1; // Company
|
||||
default:
|
||||
return categoryType; // Already a top level
|
||||
}
|
||||
};
|
||||
|
||||
// Infer hierarchy level based on type even when parent is missing
|
||||
const inferHierarchyLevel = (categoryType: number): number => {
|
||||
switch (categoryType) {
|
||||
case 10: // Section
|
||||
case 20: // Theme
|
||||
case 1: // Company
|
||||
case 40: // Artist
|
||||
return 0; // Top level
|
||||
case 11: // Category
|
||||
case 21: // Subtheme
|
||||
case 2: // Line
|
||||
return 1; // Second level
|
||||
case 12: // Subcategory
|
||||
case 3: // Subline
|
||||
return 2; // Third level
|
||||
case 13: // Sub-subcategory
|
||||
return 3; // Fourth level
|
||||
default:
|
||||
return 0; // Default to top level
|
||||
}
|
||||
};
|
||||
|
||||
// Simplify the Categories component by removing the second query and data merging
|
||||
export function Categories() {
|
||||
const [sortColumn, setSortColumn] =
|
||||
@@ -457,7 +438,7 @@ export function Categories() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: filterOptions, isLoading: isLoadingFilterOptions } = useQuery<
|
||||
const { data: filterOptions } = useQuery<
|
||||
CategoryFilterOptions,
|
||||
Error
|
||||
>({
|
||||
@@ -553,13 +534,6 @@ export function Categories() {
|
||||
const totalStockUnits = cat.current_stock_units || 0;
|
||||
const totalStockCost = parseFloat(cat.current_stock_cost?.toString() || '0');
|
||||
|
||||
// Direct values (for display in tooltips)
|
||||
const directRevenue = parseFloat(cat.direct_revenue_30d?.toString() || '0');
|
||||
const directProfit = parseFloat(cat.direct_profit_30d?.toString() || '0');
|
||||
const directActiveProducts = cat.direct_active_product_count || 0;
|
||||
const directStockUnits = cat.direct_current_stock_units || 0;
|
||||
const directStockCost = parseFloat(cat.direct_stock_cost?.toString() || '0');
|
||||
|
||||
// Set the pre-calculated totals
|
||||
directTotalsMap.set(cat.category_id, {
|
||||
revenue30d: totalRevenue,
|
||||
@@ -616,10 +590,9 @@ export function Categories() {
|
||||
cat.isExpanded = expandedCategories.has(cat.category_id);
|
||||
|
||||
// Process children to set their hierarchy levels
|
||||
const children =
|
||||
cat.children.length > 0
|
||||
? computeHierarchyAndLevels(cat.children, level + 1)
|
||||
: [];
|
||||
if (cat.children.length > 0) {
|
||||
computeHierarchyAndLevels(cat.children, level + 1);
|
||||
}
|
||||
|
||||
// Aggregated stats already set above
|
||||
return cat;
|
||||
@@ -664,10 +637,9 @@ export function Categories() {
|
||||
cat.isExpanded = expandedCategories.has(cat.category_id);
|
||||
|
||||
// Process children to set their hierarchy levels
|
||||
const children =
|
||||
cat.children.length > 0
|
||||
? computeHierarchyAndLevels(cat.children, level + 1)
|
||||
: [];
|
||||
if (cat.children.length > 0) {
|
||||
computeHierarchyAndLevels(cat.children, level + 1);
|
||||
}
|
||||
|
||||
// Make sure we set aggregatedStats for ALL categories, not just those with children
|
||||
// First check if we have pre-calculated values
|
||||
@@ -683,7 +655,9 @@ export function Categories() {
|
||||
profit30d: totals.profit30d,
|
||||
avg_margin_30d: totals.revenue30d > 0
|
||||
? (totals.profit30d / totals.revenue30d) * 100
|
||||
: 0
|
||||
: 0,
|
||||
sales_growth_30d_vs_prev: parseFloat(cat.sales_growth_30d_vs_prev?.toString() || "0"),
|
||||
revenue_growth_30d_vs_prev: parseFloat(cat.revenue_growth_30d_vs_prev?.toString() || "0")
|
||||
};
|
||||
} else {
|
||||
// If we don't have pre-calculated values (shouldn't happen with our algorithm)
|
||||
@@ -694,7 +668,9 @@ export function Categories() {
|
||||
currentStockCost: parseFloat(cat.direct_stock_cost?.toString() || "0"),
|
||||
revenue30d: parseFloat(cat.direct_revenue_30d?.toString() || "0"),
|
||||
profit30d: parseFloat(cat.direct_profit_30d?.toString() || "0"),
|
||||
avg_margin_30d: parseFloat(cat.avg_margin_30d?.toString() || "0")
|
||||
avg_margin_30d: parseFloat(cat.avg_margin_30d?.toString() || "0"),
|
||||
sales_growth_30d_vs_prev: parseFloat(cat.sales_growth_30d_vs_prev?.toString() || "0"),
|
||||
revenue_growth_30d_vs_prev: parseFloat(cat.revenue_growth_30d_vs_prev?.toString() || "0")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -953,6 +929,56 @@ export function Categories() {
|
||||
formatPercentage(category.avg_margin_30d)}
|
||||
</TableCell>
|
||||
|
||||
{/* Sales Growth Cell */}
|
||||
<TableCell className="h-16 py-2 text-right">
|
||||
{hasChildren && category.aggregatedStats ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-medium cursor-help">
|
||||
{formatGrowth(category.aggregatedStats.sales_growth_30d_vs_prev)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Sales Growth (incl. children):{" "}
|
||||
{formatGrowth(category.aggregatedStats.sales_growth_30d_vs_prev)}
|
||||
</p>
|
||||
<p>
|
||||
Directly from '{category.category_name}':{" "}
|
||||
{formatGrowth(category.sales_growth_30d_vs_prev)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
formatGrowth(category.sales_growth_30d_vs_prev)
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Revenue Growth Cell */}
|
||||
<TableCell className="h-16 py-2 text-right">
|
||||
{hasChildren && category.aggregatedStats ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-medium cursor-help">
|
||||
{formatGrowth(category.aggregatedStats.revenue_growth_30d_vs_prev)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Revenue Growth (incl. children):{" "}
|
||||
{formatGrowth(category.aggregatedStats.revenue_growth_30d_vs_prev)}
|
||||
</p>
|
||||
<p>
|
||||
Directly from '{category.category_name}':{" "}
|
||||
{formatGrowth(category.revenue_growth_30d_vs_prev)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
formatGrowth(category.revenue_growth_30d_vs_prev)
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Stock Turn (30d) Cell - Display direct value */}
|
||||
<TableCell className="h-16 py-2 text-right">
|
||||
{formatNumber(category.stock_turn_30d, 2)}
|
||||
@@ -1009,6 +1035,9 @@ export function Categories() {
|
||||
<TableCell className="text-right w-[8%]">
|
||||
<Skeleton className="h-5 w-full ml-auto" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right w-[8%]">
|
||||
<Skeleton className="h-5 w-full ml-auto" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right w-[6%]">
|
||||
<Skeleton className="h-5 w-full ml-auto" />
|
||||
</TableCell>
|
||||
@@ -1027,7 +1056,7 @@ export function Categories() {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={11}
|
||||
colSpan={13}
|
||||
className="h-16 text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{categories && categories.length > 0 ? (
|
||||
@@ -1321,6 +1350,20 @@ export function Categories() {
|
||||
Margin (30d)
|
||||
<SortIndicator active={sortColumn === "avgMargin30d"} />
|
||||
</TableHead>
|
||||
<TableHead
|
||||
onClick={() => handleSort("salesGrowth30dVsPrev")}
|
||||
className="cursor-pointer text-right w-[8%]"
|
||||
>
|
||||
Sales Growth
|
||||
<SortIndicator active={sortColumn === "salesGrowth30dVsPrev"} />
|
||||
</TableHead>
|
||||
<TableHead
|
||||
onClick={() => handleSort("revenueGrowth30dVsPrev")}
|
||||
className="cursor-pointer text-right w-[8%]"
|
||||
>
|
||||
Revenue Growth
|
||||
<SortIndicator active={sortColumn === "revenueGrowth30dVsPrev"} />
|
||||
</TableHead>
|
||||
<TableHead
|
||||
onClick={() => handleSort("stockTurn30d")}
|
||||
className="cursor-pointer text-right w-[6%]"
|
||||
|
||||
@@ -1,68 +1,90 @@
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { StockMetrics } from "@/components/dashboard/StockMetrics"
|
||||
import { PurchaseMetrics } from "@/components/dashboard/PurchaseMetrics"
|
||||
import { ReplenishmentMetrics } from "@/components/dashboard/ReplenishmentMetrics"
|
||||
import { TopReplenishProducts } from "@/components/dashboard/TopReplenishProducts"
|
||||
import { OverstockMetrics } from "@/components/dashboard/OverstockMetrics"
|
||||
import { TopOverstockedProducts } from "@/components/dashboard/TopOverstockedProducts"
|
||||
import { BestSellers } from "@/components/dashboard/BestSellers"
|
||||
import { ForecastMetrics } from "@/components/dashboard/ForecastMetrics"
|
||||
import { SalesMetrics } from "@/components/dashboard/SalesMetrics"
|
||||
import { motion } from "motion/react"
|
||||
import { ScrollProvider } from "@/contexts/DashboardScrollContext";
|
||||
import { ThemeProvider } from "@/components/dashboard/theme/ThemeProvider";
|
||||
import AircallDashboard from "@/components/dashboard/AircallDashboard";
|
||||
import EventFeed from "@/components/dashboard/EventFeed";
|
||||
import StatCards from "@/components/dashboard/StatCards";
|
||||
import ProductGrid from "@/components/dashboard/ProductGrid";
|
||||
import SalesChart from "@/components/dashboard/SalesChart";
|
||||
import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns";
|
||||
import MetaCampaigns from "@/components/dashboard/MetaCampaigns";
|
||||
import GorgiasOverview from "@/components/dashboard/GorgiasOverview";
|
||||
import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
|
||||
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
|
||||
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
|
||||
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
|
||||
import Header from "@/components/dashboard/Header";
|
||||
import Navigation from "@/components/dashboard/Navigation";
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<motion.div layout className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Overview</h2>
|
||||
</div>
|
||||
|
||||
{/* First row - Stock and Purchase metrics */}
|
||||
<div className="grid gap-4 grid-cols-2">
|
||||
<Card className="col-span-1">
|
||||
<StockMetrics />
|
||||
</Card>
|
||||
<Card className="col-span-1">
|
||||
<PurchaseMetrics />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Second row - Replenishment section */}
|
||||
<div className="grid gap-4 grid-cols-3">
|
||||
<Card className="col-span-2">
|
||||
<TopReplenishProducts />
|
||||
</Card>
|
||||
<div className="col-span-1 grid gap-4">
|
||||
<Card>
|
||||
<ReplenishmentMetrics />
|
||||
</Card>
|
||||
<Card>
|
||||
<ForecastMetrics />
|
||||
</Card>
|
||||
<ThemeProvider>
|
||||
<ScrollProvider>
|
||||
<div className="flex-1 h-full relative">
|
||||
<div className="h-full overflow-auto" id="dashboard-scroll-container">
|
||||
<div className="min-h-screen max-w-[1600px] mx-auto relative">
|
||||
<div className="p-4">
|
||||
<Header />
|
||||
</div>
|
||||
<Navigation />
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
|
||||
<div className="xl:col-span-4 col-span-6">
|
||||
<div className="space-y-4 h-full w-full">
|
||||
<div id="stats">
|
||||
<StatCards />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="realtime" className="xl:col-span-2 col-span-6 overflow-auto">
|
||||
<div className="h-full">
|
||||
<RealtimeAnalytics />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div id="feed" className="col-span-12 lg:col-span-6 xl:col-span-4 h-[600px] lg:h-[740px]">
|
||||
<EventFeed />
|
||||
</div>
|
||||
<div id="sales" className="col-span-12 xl:col-span-8 h-full w-full flex">
|
||||
<SalesChart className="w-full h-full"/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div id="products" className="col-span-12 lg:col-span-4 h-[500px]">
|
||||
<ProductGrid />
|
||||
</div>
|
||||
<div id="campaigns" className="col-span-12 lg:col-span-8">
|
||||
<KlaviyoCampaigns />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div id="analytics" className="col-span-12 xl:col-span-8">
|
||||
<AnalyticsDashboard />
|
||||
</div>
|
||||
<div id="user-behavior" className="col-span-12 xl:col-span-4">
|
||||
<UserBehaviorDashboard />
|
||||
</div>
|
||||
</div>
|
||||
<div id="meta-campaigns">
|
||||
<MetaCampaigns />
|
||||
</div>
|
||||
<div id="typeform">
|
||||
<TypeformDashboard />
|
||||
</div>
|
||||
<div id="gorgias-overview">
|
||||
<GorgiasOverview />
|
||||
</div>
|
||||
<div id="calls">
|
||||
<AircallDashboard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Third row - Overstock section */}
|
||||
<div className="grid gap-4 grid-cols-3">
|
||||
<Card className="col-span-1">
|
||||
<OverstockMetrics />
|
||||
</Card>
|
||||
<Card className="col-span-2">
|
||||
<TopOverstockedProducts />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Fourth row - Best Sellers and Sales */}
|
||||
<div className="grid gap-4 grid-cols-3">
|
||||
<Card className="col-span-2">
|
||||
<BestSellers />
|
||||
</Card>
|
||||
<Card className="col-span-1">
|
||||
<SalesMetrics />
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
</ScrollProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
export default Dashboard;
|
||||
68
inventory/src/pages/Overview.tsx
Normal file
68
inventory/src/pages/Overview.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { StockMetrics } from "@/components/overview/StockMetrics"
|
||||
import { PurchaseMetrics } from "@/components/overview/PurchaseMetrics"
|
||||
import { ReplenishmentMetrics } from "@/components/overview/ReplenishmentMetrics"
|
||||
import { TopReplenishProducts } from "@/components/overview/TopReplenishProducts"
|
||||
import { OverstockMetrics } from "@/components/overview/OverstockMetrics"
|
||||
import { TopOverstockedProducts } from "@/components/overview/TopOverstockedProducts"
|
||||
import { BestSellers } from "@/components/overview/BestSellers"
|
||||
import { ForecastMetrics } from "@/components/overview/ForecastMetrics"
|
||||
import { SalesMetrics } from "@/components/overview/SalesMetrics"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
export function Overview() {
|
||||
return (
|
||||
<motion.div layout className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Overview</h2>
|
||||
</div>
|
||||
|
||||
{/* First row - Stock and Purchase metrics */}
|
||||
<div className="grid gap-4 grid-cols-2">
|
||||
<Card className="col-span-1">
|
||||
<StockMetrics />
|
||||
</Card>
|
||||
<Card className="col-span-1">
|
||||
<PurchaseMetrics />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Second row - Replenishment section */}
|
||||
<div className="grid gap-4 grid-cols-3">
|
||||
<Card className="col-span-2">
|
||||
<TopReplenishProducts />
|
||||
</Card>
|
||||
<div className="col-span-1 grid gap-4">
|
||||
<Card>
|
||||
<ReplenishmentMetrics />
|
||||
</Card>
|
||||
<Card>
|
||||
<ForecastMetrics />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Third row - Overstock section */}
|
||||
<div className="grid gap-4 grid-cols-3">
|
||||
<Card className="col-span-1">
|
||||
<OverstockMetrics />
|
||||
</Card>
|
||||
<Card className="col-span-2">
|
||||
<TopOverstockedProducts />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Fourth row - Best Sellers and Sales */}
|
||||
<div className="grid gap-4 grid-cols-3">
|
||||
<Card className="col-span-2">
|
||||
<BestSellers />
|
||||
</Card>
|
||||
<Card className="col-span-1">
|
||||
<SalesMetrics />
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Overview
|
||||
@@ -1,24 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ProductFilters, type ActiveFilterValue } from '@/components/products/ProductFilters';
|
||||
import { ProductTable } from '@/components/products/ProductTable';
|
||||
import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton';
|
||||
import { ProductDetail } from '@/components/products/ProductDetail';
|
||||
import { ProductViews } from '@/components/products/ProductViews';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ProductMetric, ProductMetricColumnKey } from '@/types/products';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
DropdownMenuCheckboxItem
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
@@ -27,10 +22,14 @@ import {
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from "sonner";
|
||||
} from "@/components/ui/pagination";
|
||||
import { ProductMetric, ProductMetricColumnKey, ActiveFilterValue } from "@/types/products";
|
||||
import { ProductTable } from "@/components/products/ProductTable";
|
||||
import { ProductFilters } from "@/components/products/ProductFilters";
|
||||
import { ProductDetail } from "@/components/products/ProductDetail";
|
||||
import { ProductViews } from "@/components/products/ProductViews";
|
||||
import { ProductTableSkeleton } from "@/components/products/ProductTableSkeleton";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
// Column definition type
|
||||
interface ColumnDef {
|
||||
@@ -39,7 +38,7 @@ interface ColumnDef {
|
||||
group: string;
|
||||
noLabel?: boolean;
|
||||
width?: string;
|
||||
format?: (value: any) => string;
|
||||
format?: (value: any, product?: ProductMetric) => React.ReactNode;
|
||||
}
|
||||
|
||||
// Define available columns with their groups
|
||||
@@ -70,7 +69,19 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
|
||||
|
||||
// Physical Properties
|
||||
{ key: 'weight', label: 'Weight', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'dimensions', label: 'Dimensions', group: 'Physical', format: (v) => v ? `${v.length}×${v.width}×${v.height}` : '-' },
|
||||
{ key: 'length', label: 'Length', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'width', label: 'Width', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'height', label: 'Height', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'dimensions', label: 'Dimensions', group: 'Physical', format: (_, product) => {
|
||||
// Handle dimensions as separate length, width, height fields
|
||||
const length = product?.length;
|
||||
const width = product?.width;
|
||||
const height = product?.height;
|
||||
if (length && width && height) {
|
||||
return `${length}×${width}×${height}`;
|
||||
}
|
||||
return '-';
|
||||
}},
|
||||
|
||||
// Customer Engagement
|
||||
{ key: 'rating', label: 'Rating', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
@@ -182,6 +193,32 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
|
||||
{ key: 'first60DaysRevenue', label: 'First 60 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'first90DaysSales', label: 'First 90 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'first90DaysRevenue', label: 'First 90 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
|
||||
// Growth Metrics
|
||||
{ key: 'salesGrowth30dVsPrev', label: 'Sales Growth % (30d vs Prev)', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'revenueGrowth30dVsPrev', label: 'Revenue Growth % (30d vs Prev)', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'salesGrowthYoy', label: 'Sales Growth % YoY', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'revenueGrowthYoy', label: 'Revenue Growth % YoY', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
|
||||
// Demand Variability Metrics
|
||||
{ key: 'salesVariance30d', label: 'Sales Variance (30d)', group: 'Demand Variability', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'salesStdDev30d', label: 'Sales Std Dev (30d)', group: 'Demand Variability', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'salesCv30d', label: 'Sales CV % (30d)', group: 'Demand Variability', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'demandPattern', label: 'Demand Pattern', group: 'Demand Variability' },
|
||||
|
||||
// Service Level Metrics
|
||||
{ key: 'fillRate30d', label: 'Fill Rate % (30d)', group: 'Service Level', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'stockoutIncidents30d', label: 'Stockout Incidents (30d)', group: 'Service Level', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'serviceLevel30d', label: 'Service Level % (30d)', group: 'Service Level', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'lostSalesIncidents30d', label: 'Lost Sales Incidents (30d)', group: 'Service Level', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
|
||||
// Seasonality Metrics
|
||||
{ key: 'seasonalityIndex', label: 'Seasonality Index', group: 'Seasonality', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'seasonalPattern', label: 'Seasonal Pattern', group: 'Seasonality' },
|
||||
{ key: 'peakSeason', label: 'Peak Season', group: 'Seasonality' },
|
||||
|
||||
// Quality Indicators
|
||||
{ key: 'lifetimeRevenueQuality', label: 'Lifetime Revenue Quality', group: 'Data Quality' },
|
||||
];
|
||||
|
||||
// Define default columns for each view
|
||||
@@ -198,7 +235,8 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
||||
'revenue30d',
|
||||
'profit30d',
|
||||
'stockCoverInDays',
|
||||
'currentStockCost'
|
||||
'currentStockCost',
|
||||
'salesGrowth30dVsPrev'
|
||||
],
|
||||
critical: [
|
||||
'status',
|
||||
@@ -214,7 +252,9 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
||||
'earliestExpectedDate',
|
||||
'vendor',
|
||||
'dateLastReceived',
|
||||
'avgLeadTimeDays'
|
||||
'avgLeadTimeDays',
|
||||
'serviceLevel30d',
|
||||
'stockoutIncidents30d'
|
||||
],
|
||||
reorder: [
|
||||
'status',
|
||||
@@ -229,7 +269,8 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
||||
'sales30d',
|
||||
'vendor',
|
||||
'avgLeadTimeDays',
|
||||
'dateLastReceived'
|
||||
'dateLastReceived',
|
||||
'demandPattern'
|
||||
],
|
||||
overstocked: [
|
||||
'status',
|
||||
@@ -244,7 +285,8 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
||||
'stockturn30d',
|
||||
'currentStockCost',
|
||||
'overstockedCost',
|
||||
'dateLastSold'
|
||||
'dateLastSold',
|
||||
'salesVariance30d'
|
||||
],
|
||||
'at-risk': [
|
||||
'status',
|
||||
@@ -259,7 +301,9 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
||||
'sellsOutInDays',
|
||||
'dateLastSold',
|
||||
'avgLeadTimeDays',
|
||||
'profit30d'
|
||||
'profit30d',
|
||||
'fillRate30d',
|
||||
'salesGrowth30dVsPrev'
|
||||
],
|
||||
new: [
|
||||
'status',
|
||||
@@ -274,7 +318,9 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
||||
'currentCostPrice',
|
||||
'dateFirstReceived',
|
||||
'ageDays',
|
||||
'abcClass'
|
||||
'abcClass',
|
||||
'first7DaysSales',
|
||||
'first30DaysSales'
|
||||
],
|
||||
healthy: [
|
||||
'status',
|
||||
@@ -288,7 +334,9 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
||||
'profit30d',
|
||||
'margin30d',
|
||||
'gmroi30d',
|
||||
'stockturn30d'
|
||||
'stockturn30d',
|
||||
'salesGrowth30dVsPrev',
|
||||
'serviceLevel30d'
|
||||
],
|
||||
};
|
||||
|
||||
@@ -307,6 +355,7 @@ export function Products() {
|
||||
const [showNonReplenishable, setShowNonReplenishable] = useState(false);
|
||||
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
|
||||
const [, setIsLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Store visible columns and order for each view
|
||||
const [viewColumns, setViewColumns] = useState<Record<string, Set<ProductMetricColumnKey>>>(() => {
|
||||
@@ -493,6 +542,16 @@ export function Products() {
|
||||
console.log('revenue30d:', transformedProducts[0].revenue30d);
|
||||
console.log('margin30d:', transformedProducts[0].margin30d);
|
||||
console.log('markup30d:', transformedProducts[0].markup30d);
|
||||
|
||||
// Debug specific fields with issues
|
||||
console.log('configSafetyStock:', transformedProducts[0].configSafetyStock);
|
||||
console.log('length:', transformedProducts[0].length);
|
||||
console.log('width:', transformedProducts[0].width);
|
||||
console.log('height:', transformedProducts[0].height);
|
||||
console.log('first7DaysSales:', transformedProducts[0].first7DaysSales);
|
||||
console.log('first30DaysSales:', transformedProducts[0].first30DaysSales);
|
||||
console.log('first7DaysRevenue:', transformedProducts[0].first7DaysRevenue);
|
||||
console.log('first30DaysRevenue:', transformedProducts[0].first30DaysRevenue);
|
||||
}
|
||||
|
||||
// Transform the metrics response to match our expected format
|
||||
@@ -508,7 +567,11 @@ export function Products() {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error);
|
||||
toast("Failed to fetch products. Please try again.");
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to fetch products. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -790,7 +853,7 @@ export function Products() {
|
||||
columnDefs={AVAILABLE_COLUMNS}
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onRowClick={(product) => setSelectedProductId(product.pid)}
|
||||
onViewProduct={(productId) => setSelectedProductId(productId)}
|
||||
/>
|
||||
|
||||
{totalPages > 1 && (
|
||||
|
||||
126
inventory/src/pages/SmallDashboard.tsx
Normal file
126
inventory/src/pages/SmallDashboard.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useState } from "react";
|
||||
import { ThemeProvider } from "@/components/dashboard/theme/ThemeProvider";
|
||||
import LockButton from "@/components/dashboard/LockButton";
|
||||
import PinProtection from "@/components/dashboard/PinProtection";
|
||||
import DateTimeWeatherDisplay from "@/components/dashboard/DateTime";
|
||||
import MiniStatCards from "@/components/dashboard/MiniStatCards";
|
||||
import MiniRealtimeAnalytics from "@/components/dashboard/MiniRealtimeAnalytics";
|
||||
import MiniSalesChart from "@/components/dashboard/MiniSalesChart";
|
||||
import MiniEventFeed from "@/components/dashboard/MiniEventFeed";
|
||||
|
||||
// Pin Protected Layout
|
||||
const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const [isPinVerified, setIsPinVerified] = useState(() => {
|
||||
return sessionStorage.getItem("pinVerified") === "true";
|
||||
});
|
||||
|
||||
const handlePinSuccess = () => {
|
||||
setIsPinVerified(true);
|
||||
sessionStorage.setItem("pinVerified", "true");
|
||||
};
|
||||
|
||||
if (!isPinVerified) {
|
||||
return <PinProtection onSuccess={handlePinSuccess} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Small Layout
|
||||
const SmallLayout = () => {
|
||||
const DATETIME_SCALE = 2;
|
||||
const STATS_SCALE = 1.65;
|
||||
const ANALYTICS_SCALE = 1.65;
|
||||
const SALES_SCALE = 1.65;
|
||||
const FEED_SCALE = 1.65;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-screen relative">
|
||||
<span className="absolute top-4 left-4 z-50">
|
||||
<LockButton />
|
||||
</span>
|
||||
|
||||
<div className="p-4 grid grid-cols-12 gap-4">
|
||||
{/* DateTime */}
|
||||
<div className="col-span-3">
|
||||
<div style={{
|
||||
transform: `scale(${DATETIME_SCALE})`,
|
||||
transformOrigin: 'top left',
|
||||
width: `${100/DATETIME_SCALE}%`
|
||||
}}>
|
||||
<DateTimeWeatherDisplay scaleFactor={DATETIME_SCALE} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats and Analytics */}
|
||||
<div className="col-span-9">
|
||||
<div className="">
|
||||
{/* Mini Stat Cards */}
|
||||
<div>
|
||||
<div style={{
|
||||
transform: `scale(${STATS_SCALE})`,
|
||||
transformOrigin: 'top left',
|
||||
width: `${100/STATS_SCALE}%`
|
||||
}}>
|
||||
<MiniStatCards
|
||||
title="Live Stats"
|
||||
timeRange="today"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Charts Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-28">
|
||||
{/* Mini Sales Chart */}
|
||||
<div>
|
||||
<div style={{
|
||||
transform: `scale(${SALES_SCALE})`,
|
||||
transformOrigin: 'top left',
|
||||
width: `${100/SALES_SCALE}%`
|
||||
}}>
|
||||
<MiniSalesChart />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Realtime Analytics */}
|
||||
<div className="-mt-1">
|
||||
<div style={{
|
||||
transform: `scale(${ANALYTICS_SCALE})`,
|
||||
transformOrigin: 'top left',
|
||||
width: `${100/ANALYTICS_SCALE}%`,
|
||||
height: `${100/ANALYTICS_SCALE}%`
|
||||
}}>
|
||||
<MiniRealtimeAnalytics />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Feed at bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<div style={{
|
||||
transform: `scale(${FEED_SCALE})`,
|
||||
transformOrigin: 'bottom center',
|
||||
width: `${100/FEED_SCALE}%`,
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
<MiniEventFeed />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function SmallDashboard() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<PinProtectedLayout>
|
||||
<SmallLayout />
|
||||
</PinProtectedLayout>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default SmallDashboard;
|
||||
@@ -16,7 +16,8 @@ import { Label } from "@/components/ui/label";
|
||||
type VendorSortableColumns =
|
||||
| 'vendorName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
|
||||
| 'currentStockCost' | 'onOrderUnits' | 'onOrderCost' | 'avgLeadTimeDays'
|
||||
| 'revenue_30d' | 'profit_30d' | 'avg_margin_30d' | 'po_count_365d' | 'status';
|
||||
| 'revenue_30d' | 'profit_30d' | 'avg_margin_30d' | 'po_count_365d'
|
||||
| 'salesGrowth30dVsPrev' | 'revenueGrowth30dVsPrev' | 'status';
|
||||
|
||||
interface VendorMetric {
|
||||
vendor_id: string | number;
|
||||
@@ -43,6 +44,9 @@ interface VendorMetric {
|
||||
lifetime_sales: number;
|
||||
lifetime_revenue: string | number;
|
||||
avg_margin_30d: string | number | null;
|
||||
// Growth metrics
|
||||
sales_growth_30d_vs_prev: string | number | null;
|
||||
revenue_growth_30d_vs_prev: string | number | null;
|
||||
// New fields added by vendorsAggregate
|
||||
status: string;
|
||||
vendor_status: string;
|
||||
@@ -68,6 +72,8 @@ interface VendorMetric {
|
||||
lifetimeSales: number;
|
||||
lifetimeRevenue: string | number;
|
||||
avgMargin_30d: string | number | null;
|
||||
salesGrowth30dVsPrev: string | number | null;
|
||||
revenueGrowth30dVsPrev: string | number | null;
|
||||
}
|
||||
|
||||
// Define response type to avoid type errors
|
||||
@@ -162,6 +168,19 @@ const formatDays = (value: number | string | null | undefined, digits = 1): stri
|
||||
return `${value.toFixed(digits)} days`;
|
||||
};
|
||||
|
||||
// Growth formatting with color coding
|
||||
const formatGrowth = (value: number | string | null | undefined, digits = 1) => {
|
||||
if (value == null) return <span className="text-muted-foreground">N/A</span>;
|
||||
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (isNaN(numValue)) return <span className="text-muted-foreground">N/A</span>;
|
||||
|
||||
const formatted = `${numValue >= 0 ? '+' : ''}${numValue.toFixed(digits)}%`;
|
||||
const colorClass = numValue >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
|
||||
return <span className={colorClass}>{formatted}</span>;
|
||||
};
|
||||
|
||||
const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
@@ -232,7 +251,7 @@ export function Vendors() {
|
||||
});
|
||||
|
||||
// Fetch filter options
|
||||
const { data: filterOptions, isLoading: isLoadingFilterOptions } = useQuery<VendorFilterOptions, Error>({
|
||||
const { data: filterOptions } = useQuery<VendorFilterOptions, Error>({
|
||||
queryKey: ['vendorsFilterOptions'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/vendors-aggregate/filter-options`, {
|
||||
@@ -381,6 +400,8 @@ export function Vendors() {
|
||||
<TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("po_count_365d")} className="cursor-pointer text-right">POs (365d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("salesGrowth30dVsPrev")} className="cursor-pointer text-right">Sales Growth</TableHead>
|
||||
<TableHead onClick={() => handleSort("revenueGrowth30dVsPrev")} className="cursor-pointer text-right">Revenue Growth</TableHead>
|
||||
<TableHead onClick={() => handleSort("status")} className="cursor-pointer text-right">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -399,17 +420,19 @@ export function Vendors() {
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : listError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-center py-8 text-destructive">
|
||||
<TableCell colSpan={13} className="text-center py-8 text-destructive">
|
||||
Error loading vendors: {listError.message}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : vendors.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={13} className="text-center py-8 text-muted-foreground">
|
||||
No vendors found matching your criteria.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -426,6 +449,8 @@ export function Vendors() {
|
||||
<TableCell className="text-right">{formatCurrency(vendor.profit_30d as number)}</TableCell>
|
||||
<TableCell className="text-right">{formatPercentage(vendor.avg_margin_30d as number)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(vendor.po_count_365d || vendor.poCount_365d)}</TableCell>
|
||||
<TableCell className="text-right">{formatGrowth(vendor.sales_growth_30d_vs_prev)}</TableCell>
|
||||
<TableCell className="text-right">{formatGrowth(vendor.revenue_growth_30d_vs_prev)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant={getStatusVariant(vendor.status)}>
|
||||
{vendor.status || 'Unknown'}
|
||||
|
||||
175
inventory/src/services/dashboard/acotService.js
Normal file
175
inventory/src/services/dashboard/acotService.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import axios from 'axios';
|
||||
import liveDashboardConfig from '@/config/dashboard';
|
||||
|
||||
// Use the configuration for the ACOT API
|
||||
const ACOT_BASE_URL = liveDashboardConfig.acot.replace('/api/acot', '');
|
||||
|
||||
const acotApi = axios.create({
|
||||
baseURL: ACOT_BASE_URL,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Request deduplication cache
|
||||
const requestCache = new Map();
|
||||
|
||||
// Periodic cache cleanup (every 5 minutes)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const maxAge = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
for (const [key, value] of requestCache.entries()) {
|
||||
if (value.timestamp && now - value.timestamp > maxAge) {
|
||||
requestCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (requestCache.size > 0) {
|
||||
console.log(`[ACOT API] Cache cleanup: ${requestCache.size} entries remaining`);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// Retry function for timeout errors
|
||||
const retryRequest = async (requestFn, maxRetries = 2, delay = 1000) => {
|
||||
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
|
||||
try {
|
||||
return await requestFn();
|
||||
} catch (error) {
|
||||
const isTimeout = error.code === 'ECONNABORTED' || error.message.includes('timeout');
|
||||
const isLastAttempt = attempt === maxRetries + 1;
|
||||
|
||||
if (isTimeout && !isLastAttempt) {
|
||||
console.log(`[ACOT API] Timeout on attempt ${attempt}, retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
delay *= 1.5; // Exponential backoff
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Request deduplication function
|
||||
const deduplicatedRequest = async (cacheKey, requestFn, cacheDuration = 5000) => {
|
||||
// Check if we have a pending request for this key
|
||||
if (requestCache.has(cacheKey)) {
|
||||
const cached = requestCache.get(cacheKey);
|
||||
|
||||
// If it's a pending promise, return it
|
||||
if (cached.promise) {
|
||||
console.log(`[ACOT API] Deduplicating request: ${cacheKey}`);
|
||||
return cached.promise;
|
||||
}
|
||||
|
||||
// If it's cached data and still fresh, return it
|
||||
if (cached.data && Date.now() - cached.timestamp < cacheDuration) {
|
||||
console.log(`[ACOT API] Using cached data: ${cacheKey}`);
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new request
|
||||
const promise = requestFn().then(data => {
|
||||
// Cache the result
|
||||
requestCache.set(cacheKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
promise: null
|
||||
});
|
||||
return data;
|
||||
}).catch(error => {
|
||||
// Remove from cache on error
|
||||
requestCache.delete(cacheKey);
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Cache the promise while it's pending
|
||||
requestCache.set(cacheKey, {
|
||||
promise,
|
||||
timestamp: Date.now(),
|
||||
data: null
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
// Add request interceptor for logging
|
||||
acotApi.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`[ACOT API] ${config.method?.toUpperCase()} ${config.url}`, config.params);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[ACOT API] Request error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add response interceptor for logging
|
||||
acotApi.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log(`[ACOT API] Response ${response.status}:`, response.data);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[ACOT API] Response error:', error.response?.data || error.message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Cleanup function to clear cache
|
||||
const clearCache = () => {
|
||||
requestCache.clear();
|
||||
console.log('[ACOT API] Request cache cleared');
|
||||
};
|
||||
|
||||
export const acotService = {
|
||||
// Get main stats - replaces klaviyo events/stats
|
||||
getStats: async (params) => {
|
||||
const cacheKey = `stats_${JSON.stringify(params)}`;
|
||||
return deduplicatedRequest(cacheKey, () =>
|
||||
retryRequest(async () => {
|
||||
const response = await acotApi.get('/api/acot/events/stats', { params });
|
||||
return response.data;
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
// Get detailed stats - replaces klaviyo events/stats/details
|
||||
getStatsDetails: async (params) => {
|
||||
const cacheKey = `details_${JSON.stringify(params)}`;
|
||||
return deduplicatedRequest(cacheKey, () =>
|
||||
retryRequest(async () => {
|
||||
const response = await acotApi.get('/api/acot/events/stats/details', { params });
|
||||
return response.data;
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
// Get products data - replaces klaviyo events/products
|
||||
getProducts: async (params) => {
|
||||
const cacheKey = `products_${JSON.stringify(params)}`;
|
||||
return deduplicatedRequest(cacheKey, () =>
|
||||
retryRequest(async () => {
|
||||
const response = await acotApi.get('/api/acot/events/products', { params });
|
||||
return response.data;
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
// Get projections - replaces klaviyo events/projection
|
||||
getProjection: async (params) => {
|
||||
const cacheKey = `projection_${JSON.stringify(params)}`;
|
||||
return deduplicatedRequest(cacheKey, () =>
|
||||
retryRequest(async () => {
|
||||
const response = await acotApi.get('/api/acot/events/projection', { params });
|
||||
return response.data;
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
// Utility functions
|
||||
clearCache,
|
||||
};
|
||||
|
||||
export default acotService;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user