Tweaks and fixes for products table
This commit is contained in:
@@ -54,6 +54,28 @@ CREATE TABLE public.product_metrics (
|
|||||||
is_visible BOOLEAN,
|
is_visible BOOLEAN,
|
||||||
is_replenishable BOOLEAN,
|
is_replenishable BOOLEAN,
|
||||||
|
|
||||||
|
-- Additional product fields
|
||||||
|
barcode VARCHAR,
|
||||||
|
harmonized_tariff_code VARCHAR,
|
||||||
|
vendor_reference VARCHAR,
|
||||||
|
notions_reference VARCHAR,
|
||||||
|
line VARCHAR,
|
||||||
|
subline VARCHAR,
|
||||||
|
artist VARCHAR,
|
||||||
|
moq INT,
|
||||||
|
rating NUMERIC(10, 2),
|
||||||
|
reviews INT,
|
||||||
|
weight NUMERIC(14, 4),
|
||||||
|
length NUMERIC(14, 4),
|
||||||
|
width NUMERIC(14, 4),
|
||||||
|
height NUMERIC(14, 4),
|
||||||
|
country_of_origin VARCHAR,
|
||||||
|
location VARCHAR,
|
||||||
|
baskets INT,
|
||||||
|
notifies INT,
|
||||||
|
preorder_count INT,
|
||||||
|
notions_inv_count INT,
|
||||||
|
|
||||||
-- Current Status (Refreshed Hourly)
|
-- Current Status (Refreshed Hourly)
|
||||||
current_price NUMERIC(10, 2),
|
current_price NUMERIC(10, 2),
|
||||||
current_regular_price NUMERIC(10, 2),
|
current_regular_price NUMERIC(10, 2),
|
||||||
|
|||||||
@@ -8,7 +8,29 @@ dotenv.config({ path: path.join(__dirname, "../../.env") });
|
|||||||
|
|
||||||
// Utility functions
|
// Utility functions
|
||||||
const imageUrlBase = process.env.PRODUCT_IMAGE_URL_BASE || 'https://sbing.com/i/products/0000/';
|
const imageUrlBase = process.env.PRODUCT_IMAGE_URL_BASE || 'https://sbing.com/i/products/0000/';
|
||||||
const getImageUrls = (pid, iid = 1) => {
|
|
||||||
|
// Modified to accept a db connection for querying product_images
|
||||||
|
const getImageUrls = async (pid, prodConnection, iid = null) => {
|
||||||
|
// If iid isn't provided, try to get it from product_images
|
||||||
|
if (iid === null && prodConnection) {
|
||||||
|
try {
|
||||||
|
// Query for images with order=255 (default/primary images)
|
||||||
|
const [primaryImages] = await prodConnection.query(
|
||||||
|
'SELECT iid FROM product_images WHERE pid = ? AND `order` = 255 LIMIT 1',
|
||||||
|
[pid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the found iid or default to 1
|
||||||
|
iid = primaryImages.length > 0 ? primaryImages[0].iid : 1;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching primary image for pid ${pid}:`, error);
|
||||||
|
iid = 1; // Fallback to default
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use default if connection not provided
|
||||||
|
iid = iid || 1;
|
||||||
|
}
|
||||||
|
|
||||||
const paddedPid = pid.toString().padStart(6, '0');
|
const paddedPid = pid.toString().padStart(6, '0');
|
||||||
// Use padded PID only for the first 3 digits
|
// Use padded PID only for the first 3 digits
|
||||||
const prefix = paddedPid.slice(0, 3);
|
const prefix = paddedPid.slice(0, 3);
|
||||||
@@ -237,9 +259,11 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
|||||||
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||||
}).join(',');
|
}).join(',');
|
||||||
|
|
||||||
const values = batch.flatMap(row => {
|
// Process image URLs for the batch
|
||||||
const imageUrls = getImageUrls(row.pid);
|
const processedValues = [];
|
||||||
return [
|
for (const row of batch) {
|
||||||
|
const imageUrls = await getImageUrls(row.pid, prodConnection);
|
||||||
|
processedValues.push([
|
||||||
row.pid,
|
row.pid,
|
||||||
row.title,
|
row.title,
|
||||||
row.description,
|
row.description,
|
||||||
@@ -287,8 +311,10 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
|||||||
imageUrls.image_full,
|
imageUrls.image_full,
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
];
|
]);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const values = processedValues.flat();
|
||||||
|
|
||||||
const [result] = await localConnection.query(`
|
const [result] = await localConnection.query(`
|
||||||
WITH inserted_products AS (
|
WITH inserted_products AS (
|
||||||
@@ -442,9 +468,11 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||||
}).join(',');
|
}).join(',');
|
||||||
|
|
||||||
const values = batch.flatMap(row => {
|
// Process image URLs for the batch
|
||||||
const imageUrls = getImageUrls(row.pid);
|
const processedValues = [];
|
||||||
return [
|
for (const row of batch) {
|
||||||
|
const imageUrls = await getImageUrls(row.pid, prodConnection);
|
||||||
|
processedValues.push([
|
||||||
row.pid,
|
row.pid,
|
||||||
row.title,
|
row.title,
|
||||||
row.description,
|
row.description,
|
||||||
@@ -492,8 +520,10 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
imageUrls.image_full,
|
imageUrls.image_full,
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
];
|
]);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const values = processedValues.flat();
|
||||||
|
|
||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
INSERT INTO temp_products (
|
INSERT INTO temp_products (
|
||||||
@@ -665,9 +695,11 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||||
}).join(',');
|
}).join(',');
|
||||||
|
|
||||||
const values = batch.flatMap(row => {
|
// Process image URLs for the batch
|
||||||
const imageUrls = getImageUrls(row.pid);
|
const processedValues = [];
|
||||||
return [
|
for (const row of batch) {
|
||||||
|
const imageUrls = await getImageUrls(row.pid, prodConnection);
|
||||||
|
processedValues.push([
|
||||||
row.pid,
|
row.pid,
|
||||||
row.title,
|
row.title,
|
||||||
row.description,
|
row.description,
|
||||||
@@ -715,8 +747,10 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
imageUrls.image_full,
|
imageUrls.image_full,
|
||||||
row.options,
|
row.options,
|
||||||
row.tags
|
row.tags
|
||||||
];
|
]);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const values = processedValues.flat();
|
||||||
|
|
||||||
const [result] = await localConnection.query(`
|
const [result] = await localConnection.query(`
|
||||||
WITH upserted AS (
|
WITH upserted AS (
|
||||||
|
|||||||
@@ -28,6 +28,27 @@ BEGIN
|
|||||||
COALESCE(p.image_175, p.image) as image_url,
|
COALESCE(p.image_175, p.image) as image_url,
|
||||||
p.visible as is_visible,
|
p.visible as is_visible,
|
||||||
p.replenishable as is_replenishable,
|
p.replenishable as is_replenishable,
|
||||||
|
-- Add new product fields
|
||||||
|
p.barcode,
|
||||||
|
p.harmonized_tariff_code,
|
||||||
|
p.vendor_reference,
|
||||||
|
p.notions_reference,
|
||||||
|
p.line,
|
||||||
|
p.subline,
|
||||||
|
p.artist,
|
||||||
|
p.moq,
|
||||||
|
p.rating,
|
||||||
|
p.reviews,
|
||||||
|
p.weight,
|
||||||
|
p.length,
|
||||||
|
p.width,
|
||||||
|
p.height,
|
||||||
|
p.country_of_origin,
|
||||||
|
p.location,
|
||||||
|
p.baskets,
|
||||||
|
p.notifies,
|
||||||
|
p.preorder_count,
|
||||||
|
p.notions_inv_count,
|
||||||
COALESCE(p.price, 0.00) as current_price,
|
COALESCE(p.price, 0.00) as current_price,
|
||||||
COALESCE(p.regular_price, 0.00) as current_regular_price,
|
COALESCE(p.regular_price, 0.00) as current_regular_price,
|
||||||
COALESCE(p.cost_price, 0.00) as current_cost_price,
|
COALESCE(p.cost_price, 0.00) as current_cost_price,
|
||||||
@@ -36,7 +57,6 @@ BEGIN
|
|||||||
p.created_at,
|
p.created_at,
|
||||||
p.first_received,
|
p.first_received,
|
||||||
p.date_last_sold,
|
p.date_last_sold,
|
||||||
p.moq,
|
|
||||||
p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each)
|
p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each)
|
||||||
FROM public.products p
|
FROM public.products p
|
||||||
),
|
),
|
||||||
@@ -185,6 +205,9 @@ BEGIN
|
|||||||
-- Final UPSERT into product_metrics
|
-- Final UPSERT into product_metrics
|
||||||
INSERT INTO public.product_metrics (
|
INSERT INTO public.product_metrics (
|
||||||
pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable,
|
pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable,
|
||||||
|
barcode, harmonized_tariff_code, vendor_reference, notions_reference, line, subline, artist,
|
||||||
|
moq, rating, reviews, weight, length, width, height, country_of_origin, location,
|
||||||
|
baskets, notifies, preorder_count, notions_inv_count,
|
||||||
current_price, current_regular_price, current_cost_price, current_landing_cost_price,
|
current_price, current_regular_price, current_cost_price, current_landing_cost_price,
|
||||||
current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
|
current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
|
||||||
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
|
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
|
||||||
@@ -214,6 +237,9 @@ BEGIN
|
|||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.is_replenishable,
|
ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.is_replenishable,
|
||||||
|
ci.barcode, ci.harmonized_tariff_code, ci.vendor_reference, ci.notions_reference, ci.line, ci.subline, ci.artist,
|
||||||
|
ci.moq, ci.rating, ci.reviews, ci.weight, ci.length, ci.width, ci.height, ci.country_of_origin, ci.location,
|
||||||
|
ci.baskets, ci.notifies, ci.preorder_count, ci.notions_inv_count,
|
||||||
ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost,
|
ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost,
|
||||||
ci.current_stock, ci.current_stock * ci.current_effective_cost, ci.current_stock * ci.current_price, ci.current_stock * ci.current_regular_price,
|
ci.current_stock, ci.current_stock * ci.current_effective_cost, ci.current_stock * ci.current_price, ci.current_stock * ci.current_regular_price,
|
||||||
COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00), COALESCE(ooi.on_order_qty, 0) * ci.current_price, ooi.earliest_expected_date,
|
COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00), COALESCE(ooi.on_order_qty, 0) * ci.current_price, ooi.earliest_expected_date,
|
||||||
@@ -694,6 +720,9 @@ BEGIN
|
|||||||
ON CONFLICT (pid) DO UPDATE SET
|
ON CONFLICT (pid) DO UPDATE SET
|
||||||
last_calculated = EXCLUDED.last_calculated,
|
last_calculated = EXCLUDED.last_calculated,
|
||||||
sku = EXCLUDED.sku, title = EXCLUDED.title, brand = EXCLUDED.brand, vendor = EXCLUDED.vendor, image_url = EXCLUDED.image_url, is_visible = EXCLUDED.is_visible, is_replenishable = EXCLUDED.is_replenishable,
|
sku = EXCLUDED.sku, title = EXCLUDED.title, brand = EXCLUDED.brand, vendor = EXCLUDED.vendor, image_url = EXCLUDED.image_url, is_visible = EXCLUDED.is_visible, is_replenishable = EXCLUDED.is_replenishable,
|
||||||
|
barcode = EXCLUDED.barcode, harmonized_tariff_code = EXCLUDED.harmonized_tariff_code, vendor_reference = EXCLUDED.vendor_reference, notions_reference = EXCLUDED.notions_reference, line = EXCLUDED.line, subline = EXCLUDED.subline, artist = EXCLUDED.artist,
|
||||||
|
moq = EXCLUDED.moq, rating = EXCLUDED.rating, reviews = EXCLUDED.reviews, weight = EXCLUDED.weight, length = EXCLUDED.length, width = EXCLUDED.width, height = EXCLUDED.height, country_of_origin = EXCLUDED.country_of_origin, location = EXCLUDED.location,
|
||||||
|
baskets = EXCLUDED.baskets, notifies = EXCLUDED.notifies, preorder_count = EXCLUDED.preorder_count, notions_inv_count = EXCLUDED.notions_inv_count,
|
||||||
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price,
|
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price,
|
||||||
current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross,
|
current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross,
|
||||||
on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date,
|
on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date,
|
||||||
|
|||||||
@@ -19,6 +19,27 @@ const COLUMN_MAP = {
|
|||||||
imageUrl: 'pm.image_url',
|
imageUrl: 'pm.image_url',
|
||||||
isVisible: 'pm.is_visible',
|
isVisible: 'pm.is_visible',
|
||||||
isReplenishable: 'pm.is_replenishable',
|
isReplenishable: 'pm.is_replenishable',
|
||||||
|
// Additional Product Fields
|
||||||
|
barcode: 'pm.barcode',
|
||||||
|
harmonizedTariffCode: 'pm.harmonized_tariff_code',
|
||||||
|
vendorReference: 'pm.vendor_reference',
|
||||||
|
notionsReference: 'pm.notions_reference',
|
||||||
|
line: 'pm.line',
|
||||||
|
subline: 'pm.subline',
|
||||||
|
artist: 'pm.artist',
|
||||||
|
moq: 'pm.moq',
|
||||||
|
rating: 'pm.rating',
|
||||||
|
reviews: 'pm.reviews',
|
||||||
|
weight: 'pm.weight',
|
||||||
|
length: 'pm.length',
|
||||||
|
width: 'pm.width',
|
||||||
|
height: 'pm.height',
|
||||||
|
countryOfOrigin: 'pm.country_of_origin',
|
||||||
|
location: 'pm.location',
|
||||||
|
baskets: 'pm.baskets',
|
||||||
|
notifies: 'pm.notifies',
|
||||||
|
preorderCount: 'pm.preorder_count',
|
||||||
|
notionsInvCount: 'pm.notions_inv_count',
|
||||||
// Current Status
|
// Current Status
|
||||||
currentPrice: 'pm.current_price',
|
currentPrice: 'pm.current_price',
|
||||||
currentRegularPrice: 'pm.current_regular_price',
|
currentRegularPrice: 'pm.current_regular_price',
|
||||||
@@ -125,50 +146,49 @@ const COLUMN_MAP = {
|
|||||||
status: 'pm.status'
|
status: 'pm.status'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map of column types for proper sorting
|
// Define column types for use in sorting/filtering
|
||||||
|
// This helps apply correct comparison operators and sorting logic
|
||||||
const COLUMN_TYPES = {
|
const COLUMN_TYPES = {
|
||||||
// Numeric columns
|
// Numeric columns (use numeric operators and sorting)
|
||||||
pid: 'number',
|
numeric: [
|
||||||
currentPrice: 'number',
|
'pid', 'currentPrice', 'currentRegularPrice', 'currentCostPrice', 'currentLandingCostPrice',
|
||||||
currentRegularPrice: 'number',
|
'currentStock', 'currentStockCost', 'currentStockRetail', 'currentStockGross',
|
||||||
currentCostPrice: 'number',
|
'onOrderQty', 'onOrderCost', 'onOrderRetail', 'ageDays',
|
||||||
currentLandingCostPrice: 'number',
|
'sales7d', 'revenue7d', 'sales14d', 'revenue14d', 'sales30d', 'revenue30d',
|
||||||
currentStock: 'number',
|
'cogs30d', 'profit30d', 'returnsUnits30d', 'returnsRevenue30d', 'discounts30d',
|
||||||
currentStockCost: 'number',
|
'grossRevenue30d', 'grossRegularRevenue30d', 'stockoutDays30d', 'sales365d', 'revenue365d',
|
||||||
currentStockRetail: 'number',
|
'avgStockUnits30d', 'avgStockCost30d', 'avgStockRetail30d', 'avgStockGross30d',
|
||||||
currentStockGross: 'number',
|
'receivedQty30d', 'receivedCost30d', 'lifetimeSales', 'lifetimeRevenue',
|
||||||
onOrderQty: 'number',
|
'first7DaysSales', 'first7DaysRevenue', 'first30DaysSales', 'first30DaysRevenue',
|
||||||
onOrderCost: 'number',
|
'first60DaysSales', 'first60DaysRevenue', 'first90DaysSales', 'first90DaysRevenue',
|
||||||
onOrderRetail: 'number',
|
'asp30d', 'acp30d', 'avgRos30d', 'avgSalesPerDay30d', 'avgSalesPerMonth30d',
|
||||||
ageDays: 'number',
|
'margin30d', 'markup30d', 'gmroi30d', 'stockturn30d', 'returnRate30d', 'discountRate30d',
|
||||||
sales7d: 'number',
|
'stockoutRate30d', 'markdown30d', 'markdownRate30d', 'sellThrough30d', 'avgLeadTimeDays',
|
||||||
revenue7d: 'number',
|
'salesVelocityDaily', 'configLeadTime', 'configDaysOfStock', 'configSafetyStock',
|
||||||
sales14d: 'number',
|
'planningPeriodDays', 'leadTimeForecastUnits', 'daysOfStockForecastUnits',
|
||||||
revenue14d: 'number',
|
'planningPeriodForecastUnits', 'leadTimeClosingStock', 'daysOfStockClosingStock',
|
||||||
sales30d: 'number',
|
'replenishmentNeededRaw', 'replenishmentUnits', 'replenishmentCost', 'replenishmentRetail',
|
||||||
revenue30d: 'number',
|
'replenishmentProfit', 'toOrderUnits', 'forecastLostSalesUnits', 'forecastLostRevenue',
|
||||||
cogs30d: 'number',
|
'stockCoverInDays', 'poCoverInDays', 'sellsOutInDays', 'overstockedUnits',
|
||||||
profit30d: 'number',
|
'overstockedCost', 'overstockedRetail', 'yesterdaySales',
|
||||||
// ... other numeric columns
|
// New numeric columns
|
||||||
|
'moq', 'rating', 'reviews', 'weight', 'length', 'width', 'height',
|
||||||
// Date columns
|
'baskets', 'notifies', 'preorderCount', 'notionsInvCount'
|
||||||
dateCreated: 'date',
|
],
|
||||||
dateFirstReceived: 'date',
|
// Date columns (use date operators and sorting)
|
||||||
dateLastReceived: 'date',
|
date: [
|
||||||
dateFirstSold: 'date',
|
'dateCreated', 'dateFirstReceived', 'dateLastReceived', 'dateFirstSold', 'dateLastSold',
|
||||||
dateLastSold: 'date',
|
'earliestExpectedDate', 'replenishDate', 'forecastedOutOfStockDate'
|
||||||
earliestExpectedDate: 'date',
|
],
|
||||||
replenishDate: 'date',
|
// String columns (use string operators and sorting)
|
||||||
|
string: [
|
||||||
// Status column - special handling
|
'sku', 'title', 'brand', 'vendor', 'imageUrl', 'abcClass', 'status',
|
||||||
status: 'status',
|
// New string columns
|
||||||
|
'barcode', 'harmonizedTariffCode', 'vendorReference', 'notionsReference',
|
||||||
// String columns default to 'string' type
|
'line', 'subline', 'artist', 'countryOfOrigin', 'location'
|
||||||
|
],
|
||||||
// Boolean columns
|
// Boolean columns (use boolean operators and sorting)
|
||||||
isVisible: 'boolean',
|
boolean: ['isVisible', 'isReplenishable', 'isOldStock']
|
||||||
isReplenishable: 'boolean',
|
|
||||||
isOldStock: 'boolean'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Special sort handling for certain columns
|
// Special sort handling for certain columns
|
||||||
|
|||||||
@@ -21,17 +21,37 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { ProductFilterOptions, ProductMetricColumnKey } from "@/types/products";
|
import { ProductFilterOptions, ProductMetricColumnKey } from "@/types/products";
|
||||||
|
|
||||||
|
// Define operators for different filter types
|
||||||
|
const STRING_OPERATORS: ComparisonOperator[] = ["contains", "equals", "starts_with", "ends_with", "not_contains", "is_empty", "is_not_empty"];
|
||||||
|
const NUMBER_OPERATORS: ComparisonOperator[] = ["=", ">", ">=", "<", "<=", "between", "is_empty", "is_not_empty"];
|
||||||
|
const BOOLEAN_OPERATORS: ComparisonOperator[] = ["is_true", "is_false"];
|
||||||
|
const DATE_OPERATORS: ComparisonOperator[] = ["=", ">", ">=", "<", "<=", "between", "is_empty", "is_not_empty"];
|
||||||
|
const SELECT_OPERATORS: ComparisonOperator[] = ["=", "!=", "in", "not_in", "is_empty", "is_not_empty"];
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
id: ProductMetricColumnKey | 'search';
|
||||||
|
label: string;
|
||||||
|
type: "select" | "number" | "boolean" | "text" | "date" | "string";
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
group: string;
|
||||||
|
operators?: ComparisonOperator[];
|
||||||
|
}
|
||||||
|
|
||||||
type FilterValue = string | number | boolean;
|
type FilterValue = string | number | boolean;
|
||||||
type ComparisonOperator = "=" | ">" | ">=" | "<" | "<=" | "between";
|
|
||||||
|
export type ComparisonOperator =
|
||||||
|
| "=" | "!=" | ">" | ">=" | "<" | "<=" | "between"
|
||||||
|
| "contains" | "equals" | "starts_with" | "ends_with" | "not_contains"
|
||||||
|
| "in" | "not_in" | "is_empty" | "is_not_empty" | "is_true" | "is_false";
|
||||||
|
|
||||||
|
// Support both simple values and complex ones with operators
|
||||||
|
export type ActiveFilterValue = FilterValue | FilterValueWithOperator;
|
||||||
|
|
||||||
interface FilterValueWithOperator {
|
interface FilterValueWithOperator {
|
||||||
value: FilterValue | string[] | number[];
|
value: FilterValue | string[] | number[];
|
||||||
operator: ComparisonOperator;
|
operator: ComparisonOperator;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support both simple values and complex ones with operators
|
|
||||||
export type ActiveFilterValue = FilterValue | FilterValueWithOperator;
|
|
||||||
|
|
||||||
interface ActiveFilterDisplay {
|
interface ActiveFilterDisplay {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -39,71 +59,149 @@ interface ActiveFilterDisplay {
|
|||||||
displayValue: string;
|
displayValue: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterOption {
|
// Base filter options available to users
|
||||||
id: ProductMetricColumnKey | 'search';
|
|
||||||
label: string;
|
|
||||||
type: "select" | "number" | "boolean" | "text" | "date";
|
|
||||||
options?: { label: string; value: string }[];
|
|
||||||
group: string;
|
|
||||||
operators?: ComparisonOperator[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base filter options - static part of the filters, which will be merged with dynamic options
|
|
||||||
const BASE_FILTER_OPTIONS: FilterOption[] = [
|
const BASE_FILTER_OPTIONS: FilterOption[] = [
|
||||||
// Search Group
|
// Basic Info group
|
||||||
{ id: "search", label: "Search (Title, SKU...)", type: "text", group: "Search" },
|
{ id: 'sku', label: 'SKU', type: 'text', group: 'Basic Info', operators: STRING_OPERATORS },
|
||||||
|
{ id: 'title', label: 'Name', type: 'text', group: 'Basic Info', operators: STRING_OPERATORS },
|
||||||
|
{ id: 'barcode', label: 'UPC', type: 'text', group: 'Basic Info', operators: STRING_OPERATORS },
|
||||||
|
{ id: 'vendor', label: 'Supplier', type: 'select', group: 'Basic Info', operators: SELECT_OPERATORS, options: [] },
|
||||||
|
{ id: 'brand', label: 'Company', type: 'select', group: 'Basic Info', operators: SELECT_OPERATORS, options: [] },
|
||||||
|
{ id: 'line', label: 'Line', type: 'text', group: 'Basic Info', operators: STRING_OPERATORS },
|
||||||
|
{ id: 'subline', label: 'Subline', type: 'text', group: 'Basic Info', operators: STRING_OPERATORS },
|
||||||
|
{ id: 'artist', label: 'Artist', type: 'text', group: 'Basic Info', operators: STRING_OPERATORS },
|
||||||
|
{ id: 'isVisible', label: 'Visible', type: 'boolean', group: 'Basic Info', operators: BOOLEAN_OPERATORS },
|
||||||
|
{ 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' },
|
||||||
|
]},
|
||||||
|
{ id: 'dateCreated', label: 'Created Date', type: 'date', group: 'Basic Info', operators: DATE_OPERATORS },
|
||||||
|
|
||||||
// Basic Info Group
|
// Supply Chain group
|
||||||
{ id: "sku", label: "SKU", type: "text", group: "Basic Info" },
|
{ id: 'vendorReference', label: 'Supplier #', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
|
||||||
{ id: "vendor", label: "Vendor", type: "select", group: "Basic Info" },
|
{ id: 'notionsReference', label: 'Notions #', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
|
||||||
{ id: "brand", label: "Brand", type: "select", group: "Basic Info" },
|
{ id: 'harmonizedTariffCode', label: 'Tariff Code', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
|
||||||
{ id: "isVisible", label: "Is Visible", type: "select", options: [{label: 'Yes', value: 'true'}, {label: 'No', value: 'false'}], group: "Basic Info" },
|
{ id: 'countryOfOrigin', label: 'Country of Origin', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
|
||||||
{ id: "isReplenishable", label: "Is Replenishable", type: "select", options: [{label: 'Yes', value: 'true'}, {label: 'No', value: 'false'}], group: "Basic Info" },
|
{ id: 'location', label: 'Location', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
|
||||||
{ id: "dateCreated", label: "Date Created", type: "date", group: "Basic Info", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: 'moq', label: 'MOQ', type: 'number', group: 'Supply Chain', operators: NUMBER_OPERATORS },
|
||||||
{ id: "ageDays", label: "Age (Days)", type: "number", group: "Basic Info", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
|
||||||
|
|
||||||
// Status Group
|
// Physical Properties group
|
||||||
// { id: "status", label: "Status", type: "select", options: [...] } - Will be populated dynamically or handled via views
|
{ id: 'weight', label: 'Weight', type: 'number', group: 'Physical', operators: NUMBER_OPERATORS },
|
||||||
|
{ id: 'dimensions', label: 'Dimensions', type: 'text', group: 'Physical', operators: STRING_OPERATORS },
|
||||||
|
|
||||||
// Stock Group
|
// Customer Engagement group
|
||||||
{ id: "abcClass", label: "ABC Class", type: "select", group: "Stock" },
|
{ id: 'rating', label: 'Rating', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
|
||||||
{ id: "currentStock", label: "Current Stock", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: 'reviews', label: 'Reviews Count', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
|
||||||
{ id: "currentStockCost", label: "Stock Cost", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: 'baskets', label: 'Basket Adds', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
|
||||||
{ id: "currentStockRetail", label: "Stock Retail", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: 'notifies', label: 'Stock Alerts', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
|
||||||
{ id: "onOrderQty", label: "On Order Qty", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
|
||||||
{ id: "stockCoverInDays", label: "Stock Cover (Days)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
// Stock group
|
||||||
{ id: "sellsOutInDays", label: "Sells Out In (Days)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: 'currentStock', label: 'Current Stock', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||||
{ id: "overstockedUnits", label: "Overstock Units", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: 'preorderCount', label: 'Preorders', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||||
{ id: "isOldStock", label: "Is Old Stock", type: "select", options: [{label: 'Yes', value: 'true'}, {label: 'No', value: 'false'}], group: "Stock" },
|
{ 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 },
|
||||||
|
|
||||||
// Pricing Group
|
// Pricing Group
|
||||||
{ id: "currentPrice", label: "Current Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: "currentPrice", label: "Current Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "currentRegularPrice", label: "Regular Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
{ id: "currentCostPrice", label: "Cost Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: "currentCostPrice", label: "Cost Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
{ id: "currentLandingCostPrice", label: "Landing Cost", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: "currentLandingCostPrice", label: "Landing Cost", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
|
||||||
|
// Valuation Group
|
||||||
|
{ 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"] },
|
||||||
|
{ id: "receivedCost30d", label: "Received Cost (30d)", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "replenishmentCost", label: "Replenishment Cost", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "replenishmentRetail", label: "Replenishment Retail", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "replenishmentProfit", label: "Replenishment Profit", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "onOrderCost", label: "On Order Cost", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "onOrderRetail", label: "On Order Retail", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "overstockedCost", label: "Overstock Cost", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "overstockedRetail", label: "Overstock Retail", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
|
||||||
// Sales Metrics Group
|
// Sales Metrics Group
|
||||||
{ id: "sales7d", label: "Sales (7d)", 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: "revenue7d", label: "Revenue (7d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "sales14d", label: "Sales (14d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "revenue14d", label: "Revenue (14d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
{ id: "sales30d", label: "Sales (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: "sales30d", label: "Sales (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
{ id: "revenue30d", label: "Revenue (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: "revenue30d", label: "Revenue (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
{ id: "profit30d", label: "Profit (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
|
||||||
{ id: "sales365d", label: "Sales (365d)", 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: "revenue365d", label: "Revenue (365d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
{ id: "salesVelocityDaily", label: "Daily Velocity", 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: "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: "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"] },
|
||||||
|
{ id: "returnsRevenue30d", label: "Returns Revenue (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "discounts30d", label: "Discounts (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "grossRevenue30d", label: "Gross Revenue (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "grossRegularRevenue30d", label: "Gross Regular Revenue (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "asp30d", label: "ASP (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "acp30d", label: "ACP (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "avgRos30d", label: "Avg ROS (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "lifetimeSales", label: "Lifetime Sales", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "lifetimeRevenue", label: "Lifetime Revenue", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
|
||||||
|
// First Period Group
|
||||||
|
{ id: "first7DaysSales", label: "First 7 Days Sales", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "first7DaysRevenue", label: "First 7 Days Revenue", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "first30DaysSales", label: "First 30 Days Sales", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "first30DaysRevenue", label: "First 30 Days Revenue", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "first60DaysSales", label: "First 60 Days Sales", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "first60DaysRevenue", label: "First 60 Days Revenue", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "first90DaysSales", label: "First 90 Days Sales", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "first90DaysRevenue", label: "First 90 Days Revenue", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
|
||||||
// Financial KPIs
|
// Financial KPIs
|
||||||
|
{ id: "cogs30d", label: "COGS (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "profit30d", label: "Profit (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
{ id: "margin30d", label: "Margin % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: "margin30d", label: "Margin % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "markup30d", label: "Markup % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
{ id: "gmroi30d", label: "GMROI (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: "gmroi30d", label: "GMROI (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
{ id: "stockturn30d", label: "Stock Turn (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: "stockturn30d", label: "Stock Turn (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
{ id: "sellThrough30d", label: "Sell Thru % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ id: "sellThrough30d", label: "Sell Thru % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "returnRate30d", label: "Return Rate % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "discountRate30d", label: "Discount Rate % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "markdown30d", label: "Markdown (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "markdownRate30d", label: "Markdown Rate % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
|
||||||
|
// Forecasting Group
|
||||||
|
{ id: "leadTimeForecastUnits", label: "Lead Time Forecast Units", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "daysOfStockForecastUnits", label: "Days of Stock Forecast Units", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "planningPeriodForecastUnits", label: "Planning Period Forecast Units", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "leadTimeClosingStock", label: "Lead Time Closing Stock", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ id: "daysOfStockClosingStock", label: "Days of Stock Closing Stock", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||||
|
{ 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"] },
|
||||||
|
|
||||||
// Lead Time & Replenishment
|
// Lead Time & Replenishment
|
||||||
{ id: "avgLeadTimeDays", label: "Avg Lead Time", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
|
||||||
{ id: "configLeadTime", label: "Config Lead Time", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
{ 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: "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: "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"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ProductFiltersProps {
|
interface ProductFiltersProps {
|
||||||
@@ -285,13 +383,12 @@ export function ProductFilters({
|
|||||||
|
|
||||||
const activeFiltersList = React.useMemo((): ActiveFilterDisplay[] => {
|
const activeFiltersList = React.useMemo((): ActiveFilterDisplay[] => {
|
||||||
return Object.entries(activeFilters)
|
return Object.entries(activeFilters)
|
||||||
.map(([id, value]): ActiveFilterDisplay | null => {
|
.map(([id, value]): ActiveFilterDisplay => {
|
||||||
const option = processedFilterOptions.find(opt => opt.id === id);
|
const option = processedFilterOptions.find(opt => opt.id === id);
|
||||||
if (!option) return null; // Should not happen if state is clean
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: option.label,
|
label: option?.label || id,
|
||||||
value,
|
value,
|
||||||
displayValue: getFilterDisplayValue(id, value),
|
displayValue: getFilterDisplayValue(id, value),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
|||||||
zIndex: isDragging ? 10 : 1,
|
zIndex: isDragging ? 10 : 1,
|
||||||
position: 'relative' as const,
|
position: 'relative' as const,
|
||||||
touchAction: 'none' as const,
|
touchAction: 'none' as const,
|
||||||
|
width: columnDef?.width ? undefined : 'auto',
|
||||||
|
minWidth: columnDef?.key === 'imageUrl' ? '60px' : '100px',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Skip rendering content for 'noLabel' columns (like image)
|
// Skip rendering content for 'noLabel' columns (like image)
|
||||||
@@ -85,8 +87,8 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
|||||||
return (
|
return (
|
||||||
<TableHead
|
<TableHead
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{...style, width: columnDef?.width || 'auto', padding: '0.5rem' }}
|
style={style}
|
||||||
className={cn(columnDef?.width, "select-none")}
|
className={cn(columnDef?.width, "select-none", "whitespace-nowrap")}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
/>
|
/>
|
||||||
@@ -98,7 +100,7 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer select-none group",
|
"cursor-pointer select-none group whitespace-nowrap",
|
||||||
columnDef?.width,
|
columnDef?.width,
|
||||||
sortColumn === column && "bg-accent/50"
|
sortColumn === column && "bg-accent/50"
|
||||||
)}
|
)}
|
||||||
@@ -207,84 +209,102 @@ export function ProductTable({
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={() => setActiveId(null)}
|
onDragCancel={() => setActiveId(null)}
|
||||||
>
|
>
|
||||||
<div className="rounded-md border overflow-x-auto relative">
|
<div className="border rounded-md relative">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute inset-0 bg-background/70 flex items-center justify-center z-20">
|
<div className="absolute inset-0 bg-background/70 flex items-center justify-center z-20">
|
||||||
<Skeleton className="h-8 w-32" />
|
<Skeleton className="h-8 w-32" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Table className={isLoading ? 'opacity-50' : ''}>
|
<div className="overflow-x-auto">
|
||||||
<TableHeader className="sticky top-0 bg-background z-10">
|
<Table className={cn(isLoading ? 'opacity-50' : '', "w-max min-w-full")}>
|
||||||
<TableRow>
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
<SortableContext
|
|
||||||
items={orderedVisibleColumns}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{orderedVisibleColumns.map((columnKey) => (
|
|
||||||
<SortableHeader
|
|
||||||
key={columnKey}
|
|
||||||
column={columnKey}
|
|
||||||
columnDef={columnDefs.find(def => def.key === columnKey)}
|
|
||||||
onSort={onSort}
|
|
||||||
sortColumn={sortColumn}
|
|
||||||
sortDirection={sortDirection}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{products.length === 0 && !isLoading ? (
|
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<SortableContext
|
||||||
colSpan={orderedVisibleColumns.length}
|
items={orderedVisibleColumns}
|
||||||
className="text-center py-8 text-muted-foreground"
|
strategy={horizontalListSortingStrategy}
|
||||||
>
|
|
||||||
No products found matching your criteria.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
products.map((product) => (
|
|
||||||
<TableRow
|
|
||||||
key={product.pid}
|
|
||||||
onClick={() => onRowClick?.(product)}
|
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
|
||||||
data-state={isLoading ? 'loading' : undefined}
|
|
||||||
>
|
>
|
||||||
{orderedVisibleColumns.map((columnKey) => (
|
{orderedVisibleColumns.map((columnKey) => (
|
||||||
<TableCell key={`${product.pid}-${columnKey}`} className={cn(columnDefs.find(c=>c.key===columnKey)?.width)}>
|
<SortableHeader
|
||||||
{columnKey === 'imageUrl' ? (
|
key={columnKey}
|
||||||
<div className="flex items-center justify-center h-12 w-[60px]">
|
column={columnKey}
|
||||||
{product.imageUrl ? (
|
columnDef={columnDefs.find(def => def.key === columnKey)}
|
||||||
<img
|
onSort={onSort}
|
||||||
src={product.imageUrl}
|
sortColumn={sortColumn}
|
||||||
alt={product.title || 'Product image'}
|
sortDirection={sortDirection}
|
||||||
className="max-h-full max-w-full object-contain bg-white p-0.5 border rounded"
|
/>
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-10 w-10 bg-muted rounded flex items-center justify-center text-muted-foreground text-xs">No Image</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
formatColumnValue(product, columnKey)
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</SortableContext>
|
||||||
))
|
|
||||||
)}
|
|
||||||
{isLoading && products.length === 0 && Array.from({length: 10}).map((_, i) => (
|
|
||||||
<TableRow key={`skel-${i}`}>
|
|
||||||
{orderedVisibleColumns.map(key => (
|
|
||||||
<TableCell key={`skel-${i}-${key}`} className={cn(columnDefs.find(c=>c.key===key)?.width)}>
|
|
||||||
<Skeleton className={`h-5 ${key==='imageUrl' ? 'w-10 h-10' : 'w-full'}`} />
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{products.length === 0 && !isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={orderedVisibleColumns.length}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
|
No products found matching your criteria.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
products.map((product) => (
|
||||||
|
<TableRow
|
||||||
|
key={product.pid}
|
||||||
|
onClick={() => onRowClick?.(product)}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
data-state={isLoading ? 'loading' : undefined}
|
||||||
|
>
|
||||||
|
{orderedVisibleColumns.map((columnKey) => {
|
||||||
|
const colDef = columnDefs.find(c => c.key === columnKey);
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={`${product.pid}-${columnKey}`}
|
||||||
|
className={cn(
|
||||||
|
colDef?.width,
|
||||||
|
"whitespace-nowrap",
|
||||||
|
columnKey === 'title' && "max-w-[300px] truncate"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{columnKey === 'imageUrl' ? (
|
||||||
|
<div className="flex items-center justify-center h-12 w-[60px]">
|
||||||
|
{product.imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={product.imageUrl}
|
||||||
|
alt={product.title || 'Product image'}
|
||||||
|
className="max-h-full max-w-full object-contain bg-white p-0.5 border rounded"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-10 w-10 bg-muted rounded flex items-center justify-center text-muted-foreground text-xs">No Image</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
formatColumnValue(product, columnKey)
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{isLoading && products.length === 0 && Array.from({length: 10}).map((_, i) => (
|
||||||
|
<TableRow key={`skel-${i}`}>
|
||||||
|
{orderedVisibleColumns.map(key => {
|
||||||
|
const colDef = columnDefs.find(c => c.key === key);
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={`skel-${i}-${key}`}
|
||||||
|
className={cn(colDef?.width, "whitespace-nowrap")}
|
||||||
|
>
|
||||||
|
<Skeleton className={`h-5 ${key==='imageUrl' ? 'w-10 h-10' : 'w-full'}`} />
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { Product } from "@/types/products"
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -15,7 +14,6 @@ export type ProductView = {
|
|||||||
label: string
|
label: string
|
||||||
icon: any
|
icon: any
|
||||||
iconClassName: string
|
iconClassName: string
|
||||||
columns: (keyof Product)[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PRODUCT_VIEWS: ProductView[] = [
|
export const PRODUCT_VIEWS: ProductView[] = [
|
||||||
@@ -23,50 +21,43 @@ export const PRODUCT_VIEWS: ProductView[] = [
|
|||||||
id: "all",
|
id: "all",
|
||||||
label: "All Products",
|
label: "All Products",
|
||||||
icon: PackageSearch,
|
icon: PackageSearch,
|
||||||
iconClassName: "",
|
iconClassName: ""
|
||||||
columns: ["image", "title", "SKU", "stock_quantity", "price", "stock_status"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "critical",
|
id: "critical",
|
||||||
label: "Critical Stock",
|
label: "Critical Stock",
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
iconClassName: "",
|
iconClassName: ""
|
||||||
columns: ["image", "title", "SKU", "stock_quantity", "daily_sales_avg", "reorder_qty", "last_purchase_date", "lead_time_status"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "reorder",
|
id: "reorder",
|
||||||
label: "Reorder Soon",
|
label: "Reorder Soon",
|
||||||
icon: PackagePlus,
|
icon: PackagePlus,
|
||||||
iconClassName: "",
|
iconClassName: ""
|
||||||
columns: ["image", "title", "SKU", "stock_quantity", "daily_sales_avg", "reorder_qty", "last_purchase_date", "lead_time_status"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "healthy",
|
id: "healthy",
|
||||||
label: "Healthy Stock",
|
label: "Healthy Stock",
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
iconClassName: "",
|
iconClassName: ""
|
||||||
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "stock_status", "abc_class"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "at-risk",
|
id: "at-risk",
|
||||||
label: "At Risk",
|
label: "At Risk",
|
||||||
icon: Timer,
|
icon: Timer,
|
||||||
iconClassName: "",
|
iconClassName: ""
|
||||||
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "weekly_sales_avg", "days_of_inventory", "last_sale_date"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "overstocked",
|
id: "overstocked",
|
||||||
label: "Overstock",
|
label: "Overstock",
|
||||||
icon: PackageX,
|
icon: PackageX,
|
||||||
iconClassName: "",
|
iconClassName: ""
|
||||||
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "overstocked_amt", "days_of_inventory", "last_sale_date"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "new",
|
id: "new",
|
||||||
label: "New Products",
|
label: "New Products",
|
||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
iconClassName: "",
|
iconClassName: ""
|
||||||
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "stock_status", "abc_class"]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton
|
|||||||
import { ProductDetail } from '@/components/products/ProductDetail';
|
import { ProductDetail } from '@/components/products/ProductDetail';
|
||||||
import { ProductViews } from '@/components/products/ProductViews';
|
import { ProductViews } from '@/components/products/ProductViews';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Product, ProductMetric, ProductMetricColumnKey } from '@/types/products';
|
import { ProductMetric, ProductMetricColumnKey } from '@/types/products';
|
||||||
import { getProductStatus } from '@/utils/productUtils';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
@@ -45,73 +44,144 @@ interface ColumnDef {
|
|||||||
|
|
||||||
// Define available columns with their groups
|
// Define available columns with their groups
|
||||||
const AVAILABLE_COLUMNS: ColumnDef[] = [
|
const AVAILABLE_COLUMNS: ColumnDef[] = [
|
||||||
{ key: 'imageUrl', label: 'Image', group: 'Basic Info', noLabel: true, width: 'w-[60px]' },
|
// Identity & Basic Info
|
||||||
{ key: 'title', label: 'Name', group: 'Basic Info' },
|
{ key: 'imageUrl', label: 'Image', group: 'Product Identity', noLabel: true, width: 'w-[60px]' },
|
||||||
{ key: 'sku', label: 'SKU', group: 'Basic Info' },
|
{ key: 'title', label: 'Name', group: 'Product Identity'},
|
||||||
{ key: 'brand', label: 'Company', group: 'Basic Info' },
|
{ key: 'sku', label: 'Item Number', group: 'Product Identity' },
|
||||||
{ key: 'vendor', label: 'Supplier', group: 'Basic Info' },
|
{ key: 'barcode', label: 'UPC', group: 'Product Identity' },
|
||||||
{ key: 'isVisible', label: 'Visible', group: 'Basic Info' },
|
{ key: 'brand', label: 'Company', group: 'Product Identity' },
|
||||||
{ key: 'isReplenishable', label: 'Replenishable', group: 'Basic Info' },
|
{ key: 'line', label: 'Line', group: 'Product Identity' },
|
||||||
{ key: 'dateCreated', label: 'Created', group: 'Basic Info' },
|
{ key: 'subline', label: 'Subline', group: 'Product Identity' },
|
||||||
|
{ key: 'artist', label: 'Artist', group: 'Product Identity' },
|
||||||
|
{ key: 'isVisible', label: 'Visible', group: 'Product Identity' },
|
||||||
|
{ key: 'isReplenishable', label: 'Replenishable', group: 'Product Identity' },
|
||||||
|
{ key: 'abcClass', label: 'ABC Class', group: 'Product Identity' },
|
||||||
|
{ key: 'status', label: 'Status', group: 'Product Identity' },
|
||||||
|
{ key: 'dateCreated', label: 'Created', group: 'Dates' },
|
||||||
|
|
||||||
// Current Status
|
// Supply Chain
|
||||||
|
{ key: 'vendor', label: 'Supplier', group: 'Supply Chain' },
|
||||||
|
{ key: 'vendorReference', label: 'Supplier #', group: 'Supply Chain' },
|
||||||
|
{ key: 'notionsReference', label: 'Notions #', group: 'Supply Chain' },
|
||||||
|
{ key: 'harmonizedTariffCode', label: 'Tariff Code', group: 'Supply Chain' },
|
||||||
|
{ key: 'countryOfOrigin', label: 'Country', group: 'Supply Chain' },
|
||||||
|
{ key: 'location', label: 'Location', group: 'Supply Chain' },
|
||||||
|
{ key: 'moq', label: 'MOQ', group: 'Supply Chain', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
|
||||||
|
// 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}` : '-' },
|
||||||
|
|
||||||
|
// Customer Engagement
|
||||||
|
{ key: 'rating', label: 'Rating', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
|
{ key: 'reviews', label: 'Reviews', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'baskets', label: 'Basket Adds', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'notifies', label: 'Stock Alerts', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
|
||||||
|
// Inventory & Stock
|
||||||
|
{ key: 'currentStock', label: 'Current Stock', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'preorderCount', label: 'Preorders', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'notionsInvCount', label: 'Notions Inv.', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'configSafetyStock', label: 'Safety Stock', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'replenishmentUnits', label: 'Replenish Units', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
|
{ key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
|
{ key: 'onOrderQty', label: 'On Order', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'earliestExpectedDate', label: 'Expected Date', group: 'Inventory' },
|
||||||
|
{ key: 'isOldStock', label: 'Old Stock', group: 'Inventory' },
|
||||||
|
{ key: 'overstockedUnits', label: 'Overstock Qty', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'stockoutDays30d', label: 'Stockout Days (30d)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'stockoutRate30d', label: 'Stockout Rate %', group: 'Inventory', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||||
|
{ key: 'avgStockUnits30d', label: 'Avg Stock Units (30d)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
|
{ key: 'receivedQty30d', label: 'Received Qty (30d)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'poCoverInDays', label: 'PO Cover (Days)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
|
|
||||||
|
// Pricing & Costs
|
||||||
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'currentCostPrice', label: 'Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'currentCostPrice', label: 'Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'currentLandingCostPrice', label: 'Landing Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'currentLandingCostPrice', label: 'Landing Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'currentStock', label: 'Stock', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'currentStockRetail', label: 'Stock Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'currentStockRetail', label: 'Stock Retail', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'currentStockGross', label: 'Stock Gross', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'currentStockGross', label: 'Stock Gross', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'onOrderCost', label: 'On Order Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'onOrderQty', label: 'On Order', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
{ key: 'onOrderRetail', label: 'On Order Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'onOrderCost', label: 'On Order Cost', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'overstockedCost', label: 'Overstock Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'onOrderRetail', label: 'On Order Retail', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'overstockedRetail', label: 'Overstock Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'earliestExpectedDate', label: 'Expected Date', group: 'Stock' },
|
{ key: 'avgStockCost30d', label: 'Avg Stock Cost (30d)', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'avgStockRetail30d', label: 'Avg Stock Retail (30d)', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'avgStockGross30d', label: 'Avg Stock Gross (30d)', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'receivedCost30d', label: 'Received Cost (30d)', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'replenishmentCost', label: 'Replenishment Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'replenishmentRetail', label: 'Replenishment Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'replenishmentProfit', label: 'Replenishment Profit', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
|
||||||
// Dates
|
// Dates & Timing
|
||||||
{ key: 'dateFirstReceived', label: 'First Received', group: 'Dates' },
|
{ key: 'dateFirstReceived', label: 'First Received', group: 'Dates' },
|
||||||
{ key: 'dateLastReceived', label: 'Last Received', group: 'Dates' },
|
{ key: 'dateLastReceived', label: 'Last Received', group: 'Dates' },
|
||||||
{ key: 'dateFirstSold', label: 'First Sold', group: 'Dates' },
|
{ key: 'dateFirstSold', label: 'First Sold', group: 'Dates' },
|
||||||
{ key: 'dateLastSold', label: 'Last Sold', group: 'Dates' },
|
{ key: 'dateLastSold', label: 'Last Sold', group: 'Dates' },
|
||||||
{ key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
{ key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
|
{ key: 'replenishDate', label: 'Replenish Date', group: 'Dates' },
|
||||||
|
{ key: 'planningPeriodDays', label: 'Planning Period (Days)', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
|
||||||
// Product Status
|
// Sales & Revenue
|
||||||
{ key: 'status', label: 'Status', group: 'Status' },
|
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
|
{ key: 'yesterdaySales', label: 'Yesterday Sales', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
// Rolling Metrics
|
|
||||||
{ key: 'sales7d', label: 'Sales (7d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
{ key: 'sales7d', label: 'Sales (7d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
{ key: 'revenue7d', label: 'Revenue (7d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'revenue7d', label: 'Revenue (7d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'sales14d', label: 'Sales (14d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
{ key: 'sales14d', label: 'Sales (14d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
{ key: 'revenue14d', label: 'Revenue (14d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'revenue14d', label: 'Revenue (14d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'sales30d', label: 'Sales (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
{ key: 'sales30d', label: 'Sales (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
{ key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'cogs30d', label: 'COGS (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
|
||||||
{ key: 'profit30d', label: 'Profit (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
|
||||||
{ key: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
{ key: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
{ key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'avgSalesPerDay30d', label: 'Avg Sales/Day (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
|
{ key: 'avgSalesPerMonth30d', label: 'Avg Sales/Month (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
|
{ key: 'asp30d', label: 'ASP (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'acp30d', label: 'ACP (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'avgRos30d', label: 'Avg ROS (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'returnsUnits30d', label: 'Returns Units (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'returnsRevenue30d', label: 'Returns Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'discounts30d', label: 'Discounts (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'grossRevenue30d', label: 'Gross Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'grossRegularRevenue30d', label: 'Gross Regular Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'lifetimeSales', label: 'Lifetime Sales', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'lifetimeRevenue', label: 'Lifetime Revenue', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
|
||||||
// KPIs
|
// Financial Performance
|
||||||
|
{ key: 'cogs30d', label: 'COGS (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'profit30d', label: 'Profit (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'margin30d', label: 'Margin %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
{ key: 'margin30d', label: 'Margin %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||||
{ key: 'markup30d', label: 'Markup %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
{ key: 'markup30d', label: 'Markup %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||||
{ key: 'gmroi30d', label: 'GMROI', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'gmroi30d', label: 'GMROI', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'stockturn30d', label: 'Stock Turn', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'stockturn30d', label: 'Stock Turn', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
{ key: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||||
{ key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Lead Time', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
{ key: 'returnRate30d', label: 'Return Rate %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||||
|
{ key: 'discountRate30d', label: 'Discount Rate %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||||
|
{ key: 'markdown30d', label: 'Markdown (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'markdownRate30d', label: 'Markdown Rate %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||||
|
|
||||||
// Replenishment
|
// Forecasting
|
||||||
{ key: 'abcClass', label: 'ABC Class', group: 'Stock' },
|
{ key: 'leadTimeForecastUnits', label: 'Lead Time Forecast Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
{ key: 'daysOfStockForecastUnits', label: 'Days of Stock Forecast Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
{ key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
{ key: 'planningPeriodForecastUnits', label: 'Planning Period Forecast Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
{ key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
{ key: 'leadTimeClosingStock', label: 'Lead Time Closing Stock', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
{ key: 'overstockedUnits', label: 'Overstock Qty', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
{ key: 'daysOfStockClosingStock', label: 'Days of Stock Closing Stock', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
{ key: 'overstockedCost', label: 'Overstock Cost', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'replenishmentNeededRaw', label: 'Replenishment Needed Raw', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
{ key: 'overstockedRetail', label: 'Overstock Retail', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
{ key: 'forecastLostSalesUnits', label: 'Forecast Lost Sales Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||||
{ key: 'isOldStock', label: 'Old Stock', group: 'Stock' },
|
{ key: 'forecastLostRevenue', label: 'Forecast Lost Revenue', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
{ key: 'yesterdaySales', label: 'Yesterday Sales', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
|
||||||
|
|
||||||
// Config & Replenishment columns
|
// First Period Performance
|
||||||
{ key: 'configSafetyStock', label: 'Safety Stock', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
{ key: 'first7DaysSales', label: 'First 7 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
{ key: 'replenishmentUnits', label: 'Replenish Units', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
{ key: 'first7DaysRevenue', label: 'First 7 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'first30DaysSales', label: 'First 30 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ key: 'first30DaysRevenue', label: 'First 30 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||||
|
{ key: 'first60DaysSales', label: 'First 60 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||||
|
{ 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) : '-' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Define default columns for each view
|
// Define default columns for each view
|
||||||
@@ -120,84 +190,105 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
|||||||
'imageUrl',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'brand',
|
'brand',
|
||||||
'vendor',
|
|
||||||
'currentStock',
|
|
||||||
'status',
|
'status',
|
||||||
'salesVelocityDaily',
|
'currentStock',
|
||||||
'currentPrice',
|
'currentPrice',
|
||||||
'currentRegularPrice',
|
'salesVelocityDaily',
|
||||||
'sales7d',
|
|
||||||
'sales30d',
|
'sales30d',
|
||||||
'revenue30d',
|
'revenue30d',
|
||||||
'currentStockCost',
|
'profit30d',
|
||||||
|
'stockCoverInDays',
|
||||||
|
'currentStockCost'
|
||||||
],
|
],
|
||||||
critical: [
|
critical: [
|
||||||
|
'status',
|
||||||
'imageUrl',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'currentStock',
|
'currentStock',
|
||||||
'configSafetyStock',
|
'configSafetyStock',
|
||||||
'sales7d',
|
|
||||||
'sales30d',
|
|
||||||
'replenishmentUnits',
|
'replenishmentUnits',
|
||||||
'salesVelocityDaily',
|
'salesVelocityDaily',
|
||||||
|
'sales7d',
|
||||||
|
'sales30d',
|
||||||
|
'onOrderQty',
|
||||||
|
'earliestExpectedDate',
|
||||||
'vendor',
|
'vendor',
|
||||||
'dateLastReceived',
|
'dateLastReceived',
|
||||||
'avgLeadTimeDays',
|
'avgLeadTimeDays'
|
||||||
],
|
],
|
||||||
reorder: [
|
reorder: [
|
||||||
|
'status',
|
||||||
|
'imageUrl',
|
||||||
|
'title',
|
||||||
|
'currentStock',
|
||||||
|
'configSafetyStock',
|
||||||
|
'replenishmentUnits',
|
||||||
|
'salesVelocityDaily',
|
||||||
|
'sellsOutInDays',
|
||||||
|
'currentCostPrice',
|
||||||
|
'sales30d',
|
||||||
|
'vendor',
|
||||||
|
'avgLeadTimeDays',
|
||||||
|
'dateLastReceived'
|
||||||
|
],
|
||||||
|
overstocked: [
|
||||||
|
'status',
|
||||||
|
'imageUrl',
|
||||||
|
'title',
|
||||||
|
'currentStock',
|
||||||
|
'overstockedUnits',
|
||||||
|
'sales7d',
|
||||||
|
'sales30d',
|
||||||
|
'salesVelocityDaily',
|
||||||
|
'stockCoverInDays',
|
||||||
|
'stockturn30d',
|
||||||
|
'currentStockCost',
|
||||||
|
'overstockedCost',
|
||||||
|
'dateLastSold'
|
||||||
|
],
|
||||||
|
'at-risk': [
|
||||||
|
'status',
|
||||||
|
'imageUrl',
|
||||||
|
'title',
|
||||||
|
'currentStock',
|
||||||
|
'configSafetyStock',
|
||||||
|
'salesVelocityDaily',
|
||||||
|
'sales7d',
|
||||||
|
'sales30d',
|
||||||
|
'stockCoverInDays',
|
||||||
|
'sellsOutInDays',
|
||||||
|
'dateLastSold',
|
||||||
|
'avgLeadTimeDays',
|
||||||
|
'profit30d'
|
||||||
|
],
|
||||||
|
new: [
|
||||||
|
'status',
|
||||||
'imageUrl',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'currentStock',
|
'currentStock',
|
||||||
'salesVelocityDaily',
|
'salesVelocityDaily',
|
||||||
'sales7d',
|
'sales7d',
|
||||||
'sales30d',
|
|
||||||
'replenishmentUnits',
|
|
||||||
'vendor',
|
|
||||||
'dateLastReceived',
|
|
||||||
'avgLeadTimeDays',
|
|
||||||
],
|
|
||||||
overstocked: [
|
|
||||||
'imageUrl',
|
|
||||||
'title',
|
|
||||||
'currentStock',
|
|
||||||
'sales7d',
|
|
||||||
'sales30d',
|
|
||||||
'overstockedUnits',
|
|
||||||
'stockCoverInDays',
|
|
||||||
'currentStockCost',
|
|
||||||
'stockturn30d',
|
|
||||||
],
|
|
||||||
'at-risk': [
|
|
||||||
'imageUrl',
|
|
||||||
'title',
|
|
||||||
'currentStock',
|
|
||||||
'configSafetyStock',
|
|
||||||
'sales7d',
|
|
||||||
'sales30d',
|
|
||||||
'stockCoverInDays',
|
|
||||||
'dateLastSold',
|
|
||||||
'avgLeadTimeDays',
|
|
||||||
],
|
|
||||||
new: [
|
|
||||||
'imageUrl',
|
|
||||||
'title',
|
|
||||||
'currentStock',
|
|
||||||
'vendor',
|
'vendor',
|
||||||
'brand',
|
'brand',
|
||||||
'currentPrice',
|
'currentPrice',
|
||||||
'currentRegularPrice',
|
'currentCostPrice',
|
||||||
'dateFirstReceived',
|
'dateFirstReceived',
|
||||||
|
'ageDays',
|
||||||
|
'abcClass'
|
||||||
],
|
],
|
||||||
healthy: [
|
healthy: [
|
||||||
|
'status',
|
||||||
'imageUrl',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'currentStock',
|
'currentStock',
|
||||||
'sales7d',
|
'stockCoverInDays',
|
||||||
|
'salesVelocityDaily',
|
||||||
'sales30d',
|
'sales30d',
|
||||||
'revenue30d',
|
'revenue30d',
|
||||||
'stockCoverInDays',
|
|
||||||
'profit30d',
|
'profit30d',
|
||||||
|
'margin30d',
|
||||||
'gmroi30d',
|
'gmroi30d',
|
||||||
|
'stockturn30d'
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -327,7 +418,14 @@ export function Products() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeView && activeView !== 'all') {
|
if (activeView && activeView !== 'all') {
|
||||||
params.append('stock_status', activeView === 'at-risk' ? 'At Risk' : activeView);
|
const stockStatus = activeView === 'at-risk' ? 'At Risk' :
|
||||||
|
activeView === 'reorder' ? 'Reorder Soon' :
|
||||||
|
activeView === 'overstocked' ? 'Overstock' :
|
||||||
|
activeView === 'new' ? 'New' :
|
||||||
|
activeView.charAt(0).toUpperCase() + activeView.slice(1);
|
||||||
|
|
||||||
|
console.log(`View: ${activeView} → Stock Status: ${stockStatus}`);
|
||||||
|
params.append('stock_status', stockStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform filters to match API expectations
|
// Transform filters to match API expectations
|
||||||
@@ -534,7 +632,7 @@ export function Products() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
className="w-[500px] max-h-[calc(100vh-4rem)] overflow-y-auto"
|
className="w-[600px] max-h-[calc(100vh-16rem)] overflow-y-auto"
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
onPointerDownOutside={(e) => {
|
onPointerDownOutside={(e) => {
|
||||||
// Only close if clicking outside the dropdown
|
// Only close if clicking outside the dropdown
|
||||||
@@ -549,45 +647,47 @@ export function Products() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className="sticky top-0 bg-background z-10">Toggle columns</DropdownMenuLabel>
|
<div className="sticky top-0 bg-background z-10 flex items-center justify-between">
|
||||||
|
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
resetColumnsToDefault();
|
||||||
|
// Prevent closing by stopping propagation
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset to Default
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<DropdownMenuSeparator className="sticky top-[29px] bg-background z-10" />
|
<DropdownMenuSeparator className="sticky top-[29px] bg-background z-10" />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div style={{ columnCount: 3, columnGap: '2rem' }} className="p-2">
|
||||||
{Object.entries(columnsByGroup).map(([group, columns]) => (
|
{Object.entries(columnsByGroup).map(([group, columns]) => (
|
||||||
<div key={group}>
|
<div key={group} style={{ breakInside: 'avoid' }} className="mb-4">
|
||||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
<DropdownMenuLabel className="text-xs font-semibold text-muted-foreground mb-2">
|
||||||
{group}
|
{group}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{columns.map((column) => (
|
<div className="flex flex-col gap-1">
|
||||||
<DropdownMenuCheckboxItem
|
{columns.map((column) => (
|
||||||
key={column.key}
|
<DropdownMenuCheckboxItem
|
||||||
className="capitalize"
|
key={column.key}
|
||||||
checked={visibleColumns.has(column.key)}
|
className="capitalize"
|
||||||
onCheckedChange={(checked) => {
|
checked={visibleColumns.has(column.key)}
|
||||||
handleColumnVisibilityChange(column.key, checked);
|
onCheckedChange={(checked) => {
|
||||||
}}
|
handleColumnVisibilityChange(column.key, checked);
|
||||||
onSelect={(e) => {
|
}}
|
||||||
// Prevent closing by stopping propagation
|
onSelect={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{column.label}
|
{column.label}
|
||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={(e) => {
|
|
||||||
resetColumnsToDefault();
|
|
||||||
// Prevent closing by stopping propagation
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset to Default
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export interface Product {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Type for product status (used for calculated statuses)
|
// Type for product status (used for calculated statuses)
|
||||||
export type ProductStatus = "Critical" | "Reorder Soon" | "Healthy" | "Overstock" | "At Risk" | "Unknown";
|
export type ProductStatus = "Critical" | "Reorder Soon" | "Healthy" | "Overstock" | "At Risk" | "New" | "Unknown";
|
||||||
|
|
||||||
// Represents data returned by the /metrics endpoint (from product_metrics table)
|
// Represents data returned by the /metrics endpoint (from product_metrics table)
|
||||||
export interface ProductMetric {
|
export interface ProductMetric {
|
||||||
@@ -93,6 +93,30 @@ export interface ProductMetric {
|
|||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
isReplenishable: boolean;
|
isReplenishable: boolean;
|
||||||
|
|
||||||
|
// Additional Product Fields
|
||||||
|
barcode: string | null;
|
||||||
|
vendorReference: string | null; // Supplier #
|
||||||
|
notionsReference: string | null; // Notions #
|
||||||
|
preorderCount: number | null;
|
||||||
|
notionsInvCount: number | null;
|
||||||
|
harmonizedTariffCode: string | null;
|
||||||
|
line: string | null;
|
||||||
|
subline: string | null;
|
||||||
|
artist: string | null;
|
||||||
|
moq: number | null;
|
||||||
|
rating: number | null;
|
||||||
|
reviews: number | null;
|
||||||
|
weight: number | null;
|
||||||
|
dimensions: {
|
||||||
|
length: number | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
} | null;
|
||||||
|
countryOfOrigin: string | null;
|
||||||
|
location: string | null;
|
||||||
|
baskets: number | null; // Number of times added to basket
|
||||||
|
notifies: number | null; // Number of stock notifications
|
||||||
|
|
||||||
// Current Status
|
// Current Status
|
||||||
currentPrice: number | null;
|
currentPrice: number | null;
|
||||||
currentRegularPrice: number | null;
|
currentRegularPrice: number | null;
|
||||||
@@ -201,7 +225,146 @@ export interface ProductFilterOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Type for keys used in sorting/filtering (matching frontend state/UI)
|
// Type for keys used in sorting/filtering (matching frontend state/UI)
|
||||||
export type ProductMetricColumnKey = keyof Omit<ProductMetric, 'pid'> | 'pid' | 'status';
|
export type ProductMetricColumnKey =
|
||||||
|
| 'pid'
|
||||||
|
| 'title'
|
||||||
|
| 'sku'
|
||||||
|
| 'barcode'
|
||||||
|
| 'brand'
|
||||||
|
| 'line'
|
||||||
|
| 'subline'
|
||||||
|
| 'artist'
|
||||||
|
| 'vendor'
|
||||||
|
| 'vendorReference'
|
||||||
|
| 'notionsReference'
|
||||||
|
| 'harmonizedTariffCode'
|
||||||
|
| 'countryOfOrigin'
|
||||||
|
| 'location'
|
||||||
|
| 'moq'
|
||||||
|
| 'weight'
|
||||||
|
| 'dimensions'
|
||||||
|
| 'rating'
|
||||||
|
| 'reviews'
|
||||||
|
| 'baskets'
|
||||||
|
| 'notifies'
|
||||||
|
| 'preorderCount'
|
||||||
|
| 'notionsInvCount'
|
||||||
|
| 'isVisible'
|
||||||
|
| 'isReplenishable'
|
||||||
|
| 'abcClass'
|
||||||
|
| 'status'
|
||||||
|
| 'dateCreated'
|
||||||
|
| 'currentStock'
|
||||||
|
| 'currentStockCost'
|
||||||
|
| 'currentStockRetail'
|
||||||
|
| 'currentStockGross'
|
||||||
|
| 'ageDays'
|
||||||
|
| 'replenishDate'
|
||||||
|
| 'planningPeriodDays'
|
||||||
|
| 'currentPrice'
|
||||||
|
| 'currentRegularPrice'
|
||||||
|
| 'currentCostPrice'
|
||||||
|
| 'currentLandingCostPrice'
|
||||||
|
| 'configSafetyStock'
|
||||||
|
| 'replenishmentUnits'
|
||||||
|
| 'stockCoverInDays'
|
||||||
|
| 'sellsOutInDays'
|
||||||
|
| 'onOrderQty'
|
||||||
|
| 'earliestExpectedDate'
|
||||||
|
| 'isOldStock'
|
||||||
|
| 'overstockedUnits'
|
||||||
|
| 'stockoutDays30d'
|
||||||
|
| 'stockoutRate30d'
|
||||||
|
| 'avgStockUnits30d'
|
||||||
|
| 'avgStockCost30d'
|
||||||
|
| 'avgStockRetail30d'
|
||||||
|
| 'avgStockGross30d'
|
||||||
|
| 'receivedQty30d'
|
||||||
|
| 'receivedCost30d'
|
||||||
|
| 'configLeadTime'
|
||||||
|
| 'configDaysOfStock'
|
||||||
|
| 'poCoverInDays'
|
||||||
|
| 'toOrderUnits'
|
||||||
|
| 'costPrice'
|
||||||
|
| 'valueAtCost'
|
||||||
|
| 'profit'
|
||||||
|
| 'margin'
|
||||||
|
| 'targetPrice'
|
||||||
|
| 'replenishmentCost'
|
||||||
|
| 'replenishmentRetail'
|
||||||
|
| 'replenishmentProfit'
|
||||||
|
| 'onOrderCost'
|
||||||
|
| 'onOrderRetail'
|
||||||
|
| 'overstockedCost'
|
||||||
|
| 'overstockedRetail'
|
||||||
|
| 'sales7d'
|
||||||
|
| 'revenue7d'
|
||||||
|
| 'sales14d'
|
||||||
|
| 'revenue14d'
|
||||||
|
| 'sales30d'
|
||||||
|
| 'units30d'
|
||||||
|
| 'revenue30d'
|
||||||
|
| 'sales365d'
|
||||||
|
| 'revenue365d'
|
||||||
|
| 'avgSalePrice30d'
|
||||||
|
| 'avgDailySales30d'
|
||||||
|
| 'avgDailyRevenue30d'
|
||||||
|
| 'stockturnRate30d'
|
||||||
|
| 'margin30d'
|
||||||
|
| 'cogs30d'
|
||||||
|
| 'profit30d'
|
||||||
|
| 'roas30d'
|
||||||
|
| 'adSpend30d'
|
||||||
|
| 'gmroi30d'
|
||||||
|
| 'first7DaysSales'
|
||||||
|
| 'first7DaysRevenue'
|
||||||
|
| 'first30DaysSales'
|
||||||
|
| 'first30DaysRevenue'
|
||||||
|
| 'first60DaysSales'
|
||||||
|
| 'first60DaysRevenue'
|
||||||
|
| 'first90DaysSales'
|
||||||
|
| 'first90DaysRevenue'
|
||||||
|
| 'lifetimeSales'
|
||||||
|
| 'lifetimeRevenue'
|
||||||
|
| 'lifetimeAvgPrice'
|
||||||
|
| 'forecastSalesUnits'
|
||||||
|
| 'forecastSalesValue'
|
||||||
|
| 'forecastStockCover'
|
||||||
|
| 'forecastedOutOfStockDate'
|
||||||
|
| 'salesVelocity'
|
||||||
|
| 'salesVelocityDaily'
|
||||||
|
| 'dateLastSold'
|
||||||
|
| 'yesterdaySales'
|
||||||
|
| 'avgSalesPerDay30d'
|
||||||
|
| 'avgSalesPerMonth30d'
|
||||||
|
| 'returnsUnits30d'
|
||||||
|
| 'returnsRevenue30d'
|
||||||
|
| 'discounts30d'
|
||||||
|
| 'grossRevenue30d'
|
||||||
|
| 'grossRegularRevenue30d'
|
||||||
|
| 'asp30d'
|
||||||
|
| 'acp30d'
|
||||||
|
| 'avgRos30d'
|
||||||
|
| 'markup30d'
|
||||||
|
| 'stockturn30d'
|
||||||
|
| 'sellThrough30d'
|
||||||
|
| 'returnRate30d'
|
||||||
|
| 'discountRate30d'
|
||||||
|
| 'markdown30d'
|
||||||
|
| 'markdownRate30d'
|
||||||
|
| 'leadTimeForecastUnits'
|
||||||
|
| 'daysOfStockForecastUnits'
|
||||||
|
| 'planningPeriodForecastUnits'
|
||||||
|
| 'leadTimeClosingStock'
|
||||||
|
| 'daysOfStockClosingStock'
|
||||||
|
| 'replenishmentNeededRaw'
|
||||||
|
| 'forecastLostSalesUnits'
|
||||||
|
| 'forecastLostRevenue'
|
||||||
|
| 'avgLeadTimeDays'
|
||||||
|
| 'dateLastReceived'
|
||||||
|
| 'dateFirstReceived'
|
||||||
|
| 'dateFirstSold'
|
||||||
|
| 'imageUrl';
|
||||||
|
|
||||||
// Mapping frontend keys to backend query param keys
|
// Mapping frontend keys to backend query param keys
|
||||||
export const FRONTEND_TO_BACKEND_KEY_MAP: Record<string, string> = {
|
export const FRONTEND_TO_BACKEND_KEY_MAP: Record<string, string> = {
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export function getStatusBadge(status: ProductStatus): string {
|
|||||||
return '<div class="inline-flex items-center rounded-full 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 border-secondary bg-blue-600 text-white">Overstock</div>';
|
return '<div class="inline-flex items-center rounded-full 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 border-secondary bg-blue-600 text-white">Overstock</div>';
|
||||||
case 'At Risk':
|
case 'At Risk':
|
||||||
return '<div class="inline-flex items-center rounded-full 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 border-orange-500 text-orange-600">At Risk</div>';
|
return '<div class="inline-flex items-center rounded-full 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 border-orange-500 text-orange-600">At Risk</div>';
|
||||||
|
case 'New':
|
||||||
|
return '<div class="inline-flex items-center rounded-full 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 border-transparent bg-purple-600 text-white">New</div>';
|
||||||
default:
|
default:
|
||||||
return '<div class="inline-flex items-center rounded-full 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">Unknown</div>';
|
return '<div class="inline-flex items-center rounded-full 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">Unknown</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user