Tweaks and fixes for products table
This commit is contained in:
@@ -54,6 +54,28 @@ CREATE TABLE public.product_metrics (
|
||||
is_visible 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_price NUMERIC(10, 2),
|
||||
current_regular_price NUMERIC(10, 2),
|
||||
|
||||
@@ -8,7 +8,29 @@ dotenv.config({ path: path.join(__dirname, "../../.env") });
|
||||
|
||||
// Utility functions
|
||||
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');
|
||||
// Use padded PID only for the first 3 digits
|
||||
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(', ')})`;
|
||||
}).join(',');
|
||||
|
||||
const values = batch.flatMap(row => {
|
||||
const imageUrls = getImageUrls(row.pid);
|
||||
return [
|
||||
// Process image URLs for the batch
|
||||
const processedValues = [];
|
||||
for (const row of batch) {
|
||||
const imageUrls = await getImageUrls(row.pid, prodConnection);
|
||||
processedValues.push([
|
||||
row.pid,
|
||||
row.title,
|
||||
row.description,
|
||||
@@ -287,8 +311,10 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
||||
imageUrls.image_full,
|
||||
null,
|
||||
null
|
||||
];
|
||||
});
|
||||
]);
|
||||
}
|
||||
|
||||
const values = processedValues.flat();
|
||||
|
||||
const [result] = await localConnection.query(`
|
||||
WITH inserted_products AS (
|
||||
@@ -442,9 +468,11 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
||||
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||
}).join(',');
|
||||
|
||||
const values = batch.flatMap(row => {
|
||||
const imageUrls = getImageUrls(row.pid);
|
||||
return [
|
||||
// Process image URLs for the batch
|
||||
const processedValues = [];
|
||||
for (const row of batch) {
|
||||
const imageUrls = await getImageUrls(row.pid, prodConnection);
|
||||
processedValues.push([
|
||||
row.pid,
|
||||
row.title,
|
||||
row.description,
|
||||
@@ -492,8 +520,10 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
||||
imageUrls.image_full,
|
||||
null,
|
||||
null
|
||||
];
|
||||
});
|
||||
]);
|
||||
}
|
||||
|
||||
const values = processedValues.flat();
|
||||
|
||||
await localConnection.query(`
|
||||
INSERT INTO temp_products (
|
||||
@@ -665,9 +695,11 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
||||
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||
}).join(',');
|
||||
|
||||
const values = batch.flatMap(row => {
|
||||
const imageUrls = getImageUrls(row.pid);
|
||||
return [
|
||||
// Process image URLs for the batch
|
||||
const processedValues = [];
|
||||
for (const row of batch) {
|
||||
const imageUrls = await getImageUrls(row.pid, prodConnection);
|
||||
processedValues.push([
|
||||
row.pid,
|
||||
row.title,
|
||||
row.description,
|
||||
@@ -715,8 +747,10 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
||||
imageUrls.image_full,
|
||||
row.options,
|
||||
row.tags
|
||||
];
|
||||
});
|
||||
]);
|
||||
}
|
||||
|
||||
const values = processedValues.flat();
|
||||
|
||||
const [result] = await localConnection.query(`
|
||||
WITH upserted AS (
|
||||
|
||||
@@ -28,6 +28,27 @@ BEGIN
|
||||
COALESCE(p.image_175, p.image) as image_url,
|
||||
p.visible as is_visible,
|
||||
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.regular_price, 0.00) as current_regular_price,
|
||||
COALESCE(p.cost_price, 0.00) as current_cost_price,
|
||||
@@ -36,7 +57,6 @@ BEGIN
|
||||
p.created_at,
|
||||
p.first_received,
|
||||
p.date_last_sold,
|
||||
p.moq,
|
||||
p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each)
|
||||
FROM public.products p
|
||||
),
|
||||
@@ -185,6 +205,9 @@ BEGIN
|
||||
-- Final UPSERT into product_metrics
|
||||
INSERT INTO public.product_metrics (
|
||||
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_stock, current_stock_cost, current_stock_retail, current_stock_gross,
|
||||
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
|
||||
@@ -214,6 +237,9 @@ BEGIN
|
||||
)
|
||||
SELECT
|
||||
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_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,
|
||||
@@ -694,6 +720,9 @@ BEGIN
|
||||
ON CONFLICT (pid) DO UPDATE SET
|
||||
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,
|
||||
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_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,
|
||||
|
||||
@@ -19,6 +19,27 @@ const COLUMN_MAP = {
|
||||
imageUrl: 'pm.image_url',
|
||||
isVisible: 'pm.is_visible',
|
||||
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
|
||||
currentPrice: 'pm.current_price',
|
||||
currentRegularPrice: 'pm.current_regular_price',
|
||||
@@ -125,50 +146,49 @@ const COLUMN_MAP = {
|
||||
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 = {
|
||||
// Numeric columns
|
||||
pid: 'number',
|
||||
currentPrice: 'number',
|
||||
currentRegularPrice: 'number',
|
||||
currentCostPrice: 'number',
|
||||
currentLandingCostPrice: 'number',
|
||||
currentStock: 'number',
|
||||
currentStockCost: 'number',
|
||||
currentStockRetail: 'number',
|
||||
currentStockGross: 'number',
|
||||
onOrderQty: 'number',
|
||||
onOrderCost: 'number',
|
||||
onOrderRetail: 'number',
|
||||
ageDays: 'number',
|
||||
sales7d: 'number',
|
||||
revenue7d: 'number',
|
||||
sales14d: 'number',
|
||||
revenue14d: 'number',
|
||||
sales30d: 'number',
|
||||
revenue30d: 'number',
|
||||
cogs30d: 'number',
|
||||
profit30d: 'number',
|
||||
// ... other numeric columns
|
||||
|
||||
// Date columns
|
||||
dateCreated: 'date',
|
||||
dateFirstReceived: 'date',
|
||||
dateLastReceived: 'date',
|
||||
dateFirstSold: 'date',
|
||||
dateLastSold: 'date',
|
||||
earliestExpectedDate: 'date',
|
||||
replenishDate: 'date',
|
||||
|
||||
// Status column - special handling
|
||||
status: 'status',
|
||||
|
||||
// String columns default to 'string' type
|
||||
|
||||
// Boolean columns
|
||||
isVisible: 'boolean',
|
||||
isReplenishable: 'boolean',
|
||||
isOldStock: 'boolean'
|
||||
// Numeric columns (use numeric operators and sorting)
|
||||
numeric: [
|
||||
'pid', 'currentPrice', 'currentRegularPrice', 'currentCostPrice', 'currentLandingCostPrice',
|
||||
'currentStock', 'currentStockCost', 'currentStockRetail', 'currentStockGross',
|
||||
'onOrderQty', 'onOrderCost', 'onOrderRetail', 'ageDays',
|
||||
'sales7d', 'revenue7d', 'sales14d', 'revenue14d', 'sales30d', 'revenue30d',
|
||||
'cogs30d', 'profit30d', 'returnsUnits30d', 'returnsRevenue30d', 'discounts30d',
|
||||
'grossRevenue30d', 'grossRegularRevenue30d', 'stockoutDays30d', 'sales365d', 'revenue365d',
|
||||
'avgStockUnits30d', 'avgStockCost30d', 'avgStockRetail30d', 'avgStockGross30d',
|
||||
'receivedQty30d', 'receivedCost30d', 'lifetimeSales', 'lifetimeRevenue',
|
||||
'first7DaysSales', 'first7DaysRevenue', 'first30DaysSales', 'first30DaysRevenue',
|
||||
'first60DaysSales', 'first60DaysRevenue', 'first90DaysSales', 'first90DaysRevenue',
|
||||
'asp30d', 'acp30d', 'avgRos30d', 'avgSalesPerDay30d', 'avgSalesPerMonth30d',
|
||||
'margin30d', 'markup30d', 'gmroi30d', 'stockturn30d', 'returnRate30d', 'discountRate30d',
|
||||
'stockoutRate30d', 'markdown30d', 'markdownRate30d', 'sellThrough30d', 'avgLeadTimeDays',
|
||||
'salesVelocityDaily', 'configLeadTime', 'configDaysOfStock', 'configSafetyStock',
|
||||
'planningPeriodDays', 'leadTimeForecastUnits', 'daysOfStockForecastUnits',
|
||||
'planningPeriodForecastUnits', 'leadTimeClosingStock', 'daysOfStockClosingStock',
|
||||
'replenishmentNeededRaw', 'replenishmentUnits', 'replenishmentCost', 'replenishmentRetail',
|
||||
'replenishmentProfit', 'toOrderUnits', 'forecastLostSalesUnits', 'forecastLostRevenue',
|
||||
'stockCoverInDays', 'poCoverInDays', 'sellsOutInDays', 'overstockedUnits',
|
||||
'overstockedCost', 'overstockedRetail', 'yesterdaySales',
|
||||
// New numeric columns
|
||||
'moq', 'rating', 'reviews', 'weight', 'length', 'width', 'height',
|
||||
'baskets', 'notifies', 'preorderCount', 'notionsInvCount'
|
||||
],
|
||||
// Date columns (use date operators and sorting)
|
||||
date: [
|
||||
'dateCreated', 'dateFirstReceived', 'dateLastReceived', 'dateFirstSold', 'dateLastSold',
|
||||
'earliestExpectedDate', 'replenishDate', 'forecastedOutOfStockDate'
|
||||
],
|
||||
// String columns (use string operators and sorting)
|
||||
string: [
|
||||
'sku', 'title', 'brand', 'vendor', 'imageUrl', 'abcClass', 'status',
|
||||
// New string columns
|
||||
'barcode', 'harmonizedTariffCode', 'vendorReference', 'notionsReference',
|
||||
'line', 'subline', 'artist', 'countryOfOrigin', 'location'
|
||||
],
|
||||
// Boolean columns (use boolean operators and sorting)
|
||||
boolean: ['isVisible', 'isReplenishable', 'isOldStock']
|
||||
};
|
||||
|
||||
// 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 { 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 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 {
|
||||
value: FilterValue | string[] | number[];
|
||||
operator: ComparisonOperator;
|
||||
}
|
||||
|
||||
// Support both simple values and complex ones with operators
|
||||
export type ActiveFilterValue = FilterValue | FilterValueWithOperator;
|
||||
|
||||
interface ActiveFilterDisplay {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -39,71 +59,149 @@ interface ActiveFilterDisplay {
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
export interface FilterOption {
|
||||
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
|
||||
// Base filter options available to users
|
||||
const BASE_FILTER_OPTIONS: FilterOption[] = [
|
||||
// Search Group
|
||||
{ id: "search", label: "Search (Title, SKU...)", type: "text", group: "Search" },
|
||||
// Basic Info group
|
||||
{ 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
|
||||
{ id: "sku", label: "SKU", type: "text", group: "Basic Info" },
|
||||
{ id: "vendor", label: "Vendor", type: "select", group: "Basic Info" },
|
||||
{ id: "brand", label: "Brand", type: "select", group: "Basic Info" },
|
||||
{ id: "isVisible", label: "Is Visible", type: "select", options: [{label: 'Yes', value: 'true'}, {label: 'No', value: 'false'}], group: "Basic Info" },
|
||||
{ id: "isReplenishable", label: "Is Replenishable", type: "select", options: [{label: 'Yes', value: 'true'}, {label: 'No', value: 'false'}], group: "Basic Info" },
|
||||
{ id: "dateCreated", label: "Date Created", type: "date", group: "Basic Info", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "ageDays", label: "Age (Days)", type: "number", group: "Basic Info", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
// Supply Chain group
|
||||
{ id: 'vendorReference', label: 'Supplier #', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
|
||||
{ id: 'notionsReference', label: 'Notions #', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
|
||||
{ id: 'harmonizedTariffCode', label: 'Tariff Code', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
|
||||
{ id: 'countryOfOrigin', label: 'Country of Origin', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
|
||||
{ id: 'location', label: 'Location', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
|
||||
{ id: 'moq', label: 'MOQ', type: 'number', group: 'Supply Chain', operators: NUMBER_OPERATORS },
|
||||
|
||||
// Status Group
|
||||
// { id: "status", label: "Status", type: "select", options: [...] } - Will be populated dynamically or handled via views
|
||||
// Physical Properties group
|
||||
{ id: 'weight', label: 'Weight', type: 'number', group: 'Physical', operators: NUMBER_OPERATORS },
|
||||
{ id: 'dimensions', label: 'Dimensions', type: 'text', group: 'Physical', operators: STRING_OPERATORS },
|
||||
|
||||
// Stock Group
|
||||
{ id: "abcClass", label: "ABC Class", type: "select", group: "Stock" },
|
||||
{ id: "currentStock", label: "Current Stock", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "currentStockCost", label: "Stock Cost", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "currentStockRetail", label: "Stock Retail", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "onOrderQty", label: "On Order Qty", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "stockCoverInDays", label: "Stock Cover (Days)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "sellsOutInDays", label: "Sells Out In (Days)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "overstockedUnits", label: "Overstock Units", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "isOldStock", label: "Is Old Stock", type: "select", options: [{label: 'Yes', value: 'true'}, {label: 'No', value: 'false'}], group: "Stock" },
|
||||
// Customer Engagement group
|
||||
{ id: 'rating', label: 'Rating', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
|
||||
{ id: 'reviews', label: 'Reviews Count', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
|
||||
{ id: 'baskets', label: 'Basket Adds', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
|
||||
{ id: 'notifies', label: 'Stock Alerts', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
|
||||
|
||||
// Stock group
|
||||
{ id: 'currentStock', label: 'Current Stock', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'preorderCount', label: 'Preorders', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'notionsInvCount', label: 'Notions Inventory', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'onOrderQty', label: 'On Order', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'configSafetyStock', label: 'Safety Stock', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'replenishmentUnits', label: 'Replenish Qty', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'toOrderUnits', label: 'To Order', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'stockCoverInDays', label: 'Stock Cover (Days)', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'sellsOutInDays', label: 'Sells Out In (Days)', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
{ id: 'isOldStock', label: 'Old Stock', type: 'boolean', group: 'Stock', operators: BOOLEAN_OPERATORS },
|
||||
{ id: 'overstockedUnits', label: 'Overstock Qty', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
|
||||
|
||||
// Pricing Group
|
||||
{ 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: "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
|
||||
{ id: "sales7d", label: "Sales (7d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "revenue7d", label: "Revenue (7d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "sales14d", label: "Sales (14d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ 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: "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: "revenue365d", label: "Revenue (365d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "salesVelocityDaily", label: "Daily Velocity", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "dateLastSold", label: "Date Last Sold", type: "date", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "yesterdaySales", label: "Sales (Yesterday)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "avgSalesPerDay30d", label: "Avg Sales/Day (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "avgSalesPerMonth30d", label: "Avg Sales/Month (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "returnsUnits30d", label: "Returns Units (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ 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
|
||||
{ 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: "markup30d", label: "Markup % (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: "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
|
||||
{ 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: "configDaysOfStock", label: "Config Days of Stock", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "avgLeadTimeDays", label: "Avg Lead Time", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "earliestExpectedDate", label: "Next Arrival Date", type: "date", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "dateLastReceived", label: "Date Last Received", type: "date", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "dateFirstReceived", label: "First Received", type: "date", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
{ id: "dateFirstSold", label: "First Sold", type: "date", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
|
||||
];
|
||||
|
||||
interface ProductFiltersProps {
|
||||
@@ -285,13 +383,12 @@ export function ProductFilters({
|
||||
|
||||
const activeFiltersList = React.useMemo((): ActiveFilterDisplay[] => {
|
||||
return Object.entries(activeFilters)
|
||||
.map(([id, value]): ActiveFilterDisplay | null => {
|
||||
.map(([id, value]): ActiveFilterDisplay => {
|
||||
const option = processedFilterOptions.find(opt => opt.id === id);
|
||||
if (!option) return null; // Should not happen if state is clean
|
||||
|
||||
return {
|
||||
id,
|
||||
label: option.label,
|
||||
label: option?.label || id,
|
||||
value,
|
||||
displayValue: getFilterDisplayValue(id, value),
|
||||
};
|
||||
|
||||
@@ -78,6 +78,8 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
position: 'relative' 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)
|
||||
@@ -85,8 +87,8 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
||||
return (
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={{...style, width: columnDef?.width || 'auto', padding: '0.5rem' }}
|
||||
className={cn(columnDef?.width, "select-none")}
|
||||
style={style}
|
||||
className={cn(columnDef?.width, "select-none", "whitespace-nowrap")}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
@@ -98,7 +100,7 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"cursor-pointer select-none group",
|
||||
"cursor-pointer select-none group whitespace-nowrap",
|
||||
columnDef?.width,
|
||||
sortColumn === column && "bg-accent/50"
|
||||
)}
|
||||
@@ -207,84 +209,102 @@ export function ProductTable({
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => setActiveId(null)}
|
||||
>
|
||||
<div className="rounded-md border overflow-x-auto relative">
|
||||
<div className="border rounded-md relative">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-background/70 flex items-center justify-center z-20">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</div>
|
||||
)}
|
||||
<Table className={isLoading ? 'opacity-50' : ''}>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<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 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className={cn(isLoading ? 'opacity-50' : '', "w-max min-w-full")}>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<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}
|
||||
<SortableContext
|
||||
items={orderedVisibleColumns}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{orderedVisibleColumns.map((columnKey) => (
|
||||
<TableCell key={`${product.pid}-${columnKey}`} className={cn(columnDefs.find(c=>c.key===columnKey)?.width)}>
|
||||
{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>
|
||||
<SortableHeader
|
||||
key={columnKey}
|
||||
column={columnKey}
|
||||
columnDef={columnDefs.find(def => def.key === columnKey)}
|
||||
onSort={onSort}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
{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>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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>
|
||||
</DndContext>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Product } from "@/types/products"
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
@@ -15,7 +14,6 @@ export type ProductView = {
|
||||
label: string
|
||||
icon: any
|
||||
iconClassName: string
|
||||
columns: (keyof Product)[]
|
||||
}
|
||||
|
||||
export const PRODUCT_VIEWS: ProductView[] = [
|
||||
@@ -23,50 +21,43 @@ export const PRODUCT_VIEWS: ProductView[] = [
|
||||
id: "all",
|
||||
label: "All Products",
|
||||
icon: PackageSearch,
|
||||
iconClassName: "",
|
||||
columns: ["image", "title", "SKU", "stock_quantity", "price", "stock_status"]
|
||||
iconClassName: ""
|
||||
},
|
||||
{
|
||||
id: "critical",
|
||||
label: "Critical Stock",
|
||||
icon: AlertTriangle,
|
||||
iconClassName: "",
|
||||
columns: ["image", "title", "SKU", "stock_quantity", "daily_sales_avg", "reorder_qty", "last_purchase_date", "lead_time_status"]
|
||||
iconClassName: ""
|
||||
},
|
||||
{
|
||||
id: "reorder",
|
||||
label: "Reorder Soon",
|
||||
icon: PackagePlus,
|
||||
iconClassName: "",
|
||||
columns: ["image", "title", "SKU", "stock_quantity", "daily_sales_avg", "reorder_qty", "last_purchase_date", "lead_time_status"]
|
||||
iconClassName: ""
|
||||
},
|
||||
{
|
||||
id: "healthy",
|
||||
label: "Healthy Stock",
|
||||
icon: CheckCircle2,
|
||||
iconClassName: "",
|
||||
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "stock_status", "abc_class"]
|
||||
iconClassName: ""
|
||||
},
|
||||
{
|
||||
id: "at-risk",
|
||||
label: "At Risk",
|
||||
icon: Timer,
|
||||
iconClassName: "",
|
||||
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "weekly_sales_avg", "days_of_inventory", "last_sale_date"]
|
||||
iconClassName: ""
|
||||
},
|
||||
{
|
||||
id: "overstocked",
|
||||
label: "Overstock",
|
||||
icon: PackageX,
|
||||
iconClassName: "",
|
||||
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "overstocked_amt", "days_of_inventory", "last_sale_date"]
|
||||
iconClassName: ""
|
||||
},
|
||||
{
|
||||
id: "new",
|
||||
label: "New Products",
|
||||
icon: Sparkles,
|
||||
iconClassName: "",
|
||||
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "stock_status", "abc_class"]
|
||||
iconClassName: ""
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton
|
||||
import { ProductDetail } from '@/components/products/ProductDetail';
|
||||
import { ProductViews } from '@/components/products/ProductViews';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Product, ProductMetric, ProductMetricColumnKey } from '@/types/products';
|
||||
import { getProductStatus } from '@/utils/productUtils';
|
||||
import { ProductMetric, ProductMetricColumnKey } from '@/types/products';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -45,73 +44,144 @@ interface ColumnDef {
|
||||
|
||||
// Define available columns with their groups
|
||||
const AVAILABLE_COLUMNS: ColumnDef[] = [
|
||||
{ key: 'imageUrl', label: 'Image', group: 'Basic Info', noLabel: true, width: 'w-[60px]' },
|
||||
{ key: 'title', label: 'Name', group: 'Basic Info' },
|
||||
{ key: 'sku', label: 'SKU', group: 'Basic Info' },
|
||||
{ key: 'brand', label: 'Company', group: 'Basic Info' },
|
||||
{ key: 'vendor', label: 'Supplier', group: 'Basic Info' },
|
||||
{ key: 'isVisible', label: 'Visible', group: 'Basic Info' },
|
||||
{ key: 'isReplenishable', label: 'Replenishable', group: 'Basic Info' },
|
||||
{ key: 'dateCreated', label: 'Created', group: 'Basic Info' },
|
||||
// Identity & Basic Info
|
||||
{ key: 'imageUrl', label: 'Image', group: 'Product Identity', noLabel: true, width: 'w-[60px]' },
|
||||
{ key: 'title', label: 'Name', group: 'Product Identity'},
|
||||
{ key: 'sku', label: 'Item Number', group: 'Product Identity' },
|
||||
{ key: 'barcode', label: 'UPC', group: 'Product Identity' },
|
||||
{ key: 'brand', label: 'Company', group: 'Product Identity' },
|
||||
{ key: 'line', label: 'Line', group: 'Product Identity' },
|
||||
{ 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: '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: '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: 'Stock', 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: 'Stock', 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: 'onOrderCost', label: 'On Order Cost', group: 'Stock', 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: 'earliestExpectedDate', label: 'Expected Date', group: 'Stock' },
|
||||
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Valuation', 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: 'currentStockGross', label: 'Stock Gross', group: 'Valuation', 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: 'onOrderRetail', label: 'On Order Retail', group: 'Valuation', 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: 'overstockedRetail', label: 'Overstock Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ 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: 'dateLastReceived', label: 'Last Received', group: 'Dates' },
|
||||
{ key: 'dateFirstSold', label: 'First 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: '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
|
||||
{ key: 'status', label: 'Status', group: 'Status' },
|
||||
|
||||
// Rolling Metrics
|
||||
// Sales & Revenue
|
||||
{ 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() : '-' },
|
||||
{ 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: '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: '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: '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: '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: '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: '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: '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
|
||||
{ key: 'abcClass', label: 'ABC Class', group: 'Stock' },
|
||||
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', 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: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Stock', 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: 'overstockedCost', label: 'Overstock Cost', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'overstockedRetail', label: 'Overstock Retail', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'isOldStock', label: 'Old Stock', group: 'Stock' },
|
||||
{ key: 'yesterdaySales', label: 'Yesterday Sales', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
// Forecasting
|
||||
{ key: 'leadTimeForecastUnits', label: 'Lead Time Forecast Units', group: 'Forecasting', 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: 'planningPeriodForecastUnits', label: 'Planning Period Forecast Units', group: 'Forecasting', 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: 'daysOfStockClosingStock', label: 'Days of Stock Closing Stock', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'replenishmentNeededRaw', label: 'Replenishment Needed Raw', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'forecastLostSalesUnits', label: 'Forecast Lost Sales Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'forecastLostRevenue', label: 'Forecast Lost Revenue', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
|
||||
// Config & Replenishment columns
|
||||
{ key: 'configSafetyStock', label: 'Safety Stock', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'replenishmentUnits', label: 'Replenish Units', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
// First Period Performance
|
||||
{ key: 'first7DaysSales', label: 'First 7 Days Sales', group: 'First Period', 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
|
||||
@@ -120,84 +190,105 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
||||
'imageUrl',
|
||||
'title',
|
||||
'brand',
|
||||
'vendor',
|
||||
'currentStock',
|
||||
'status',
|
||||
'salesVelocityDaily',
|
||||
'currentStock',
|
||||
'currentPrice',
|
||||
'currentRegularPrice',
|
||||
'sales7d',
|
||||
'salesVelocityDaily',
|
||||
'sales30d',
|
||||
'revenue30d',
|
||||
'currentStockCost',
|
||||
'profit30d',
|
||||
'stockCoverInDays',
|
||||
'currentStockCost'
|
||||
],
|
||||
critical: [
|
||||
'status',
|
||||
'imageUrl',
|
||||
'title',
|
||||
'currentStock',
|
||||
'configSafetyStock',
|
||||
'sales7d',
|
||||
'sales30d',
|
||||
'replenishmentUnits',
|
||||
'salesVelocityDaily',
|
||||
'sales7d',
|
||||
'sales30d',
|
||||
'onOrderQty',
|
||||
'earliestExpectedDate',
|
||||
'vendor',
|
||||
'dateLastReceived',
|
||||
'avgLeadTimeDays',
|
||||
'avgLeadTimeDays'
|
||||
],
|
||||
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',
|
||||
'title',
|
||||
'currentStock',
|
||||
'salesVelocityDaily',
|
||||
'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',
|
||||
'brand',
|
||||
'currentPrice',
|
||||
'currentRegularPrice',
|
||||
'currentCostPrice',
|
||||
'dateFirstReceived',
|
||||
'ageDays',
|
||||
'abcClass'
|
||||
],
|
||||
healthy: [
|
||||
'status',
|
||||
'imageUrl',
|
||||
'title',
|
||||
'currentStock',
|
||||
'sales7d',
|
||||
'stockCoverInDays',
|
||||
'salesVelocityDaily',
|
||||
'sales30d',
|
||||
'revenue30d',
|
||||
'stockCoverInDays',
|
||||
'profit30d',
|
||||
'margin30d',
|
||||
'gmroi30d',
|
||||
'stockturn30d'
|
||||
],
|
||||
};
|
||||
|
||||
@@ -327,7 +418,14 @@ export function Products() {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -534,7 +632,7 @@ export function Products() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
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()}
|
||||
onPointerDownOutside={(e) => {
|
||||
// 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" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div style={{ columnCount: 3, columnGap: '2rem' }} className="p-2">
|
||||
{Object.entries(columnsByGroup).map(([group, columns]) => (
|
||||
<div key={group}>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
<div key={group} style={{ breakInside: 'avoid' }} className="mb-4">
|
||||
<DropdownMenuLabel className="text-xs font-semibold text-muted-foreground mb-2">
|
||||
{group}
|
||||
</DropdownMenuLabel>
|
||||
{columns.map((column) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.key}
|
||||
className="capitalize"
|
||||
checked={visibleColumns.has(column.key)}
|
||||
onCheckedChange={(checked) => {
|
||||
handleColumnVisibilityChange(column.key, checked);
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
// Prevent closing by stopping propagation
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<div className="flex flex-col gap-1">
|
||||
{columns.map((column) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.key}
|
||||
className="capitalize"
|
||||
checked={visibleColumns.has(column.key)}
|
||||
onCheckedChange={(checked) => {
|
||||
handleColumnVisibilityChange(column.key, checked);
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</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>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -80,7 +80,7 @@ export interface Product {
|
||||
}
|
||||
|
||||
// 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)
|
||||
export interface ProductMetric {
|
||||
@@ -93,6 +93,30 @@ export interface ProductMetric {
|
||||
isVisible: 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
|
||||
currentPrice: number | null;
|
||||
currentRegularPrice: number | null;
|
||||
@@ -201,7 +225,146 @@ export interface ProductFilterOptions {
|
||||
}
|
||||
|
||||
// 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
|
||||
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>';
|
||||
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>';
|
||||
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:
|
||||
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