Fix/enhance forecasting page
This commit is contained in:
@@ -1,6 +1,169 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Forecasting: summarize sales for products received in a period by brand
|
||||
router.get('/forecast', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
const brand = (req.query.brand || '').toString();
|
||||
const titleSearch = (req.query.search || req.query.q || '').toString().trim() || null;
|
||||
const startDateStr = req.query.startDate;
|
||||
const endDateStr = req.query.endDate;
|
||||
|
||||
if (!brand) {
|
||||
return res.status(400).json({ error: 'Missing required parameter: brand' });
|
||||
}
|
||||
|
||||
// Default to last 30 days if no dates provided
|
||||
const endDate = endDateStr ? new Date(endDateStr) : new Date();
|
||||
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Normalize to date boundaries for consistency
|
||||
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
|
||||
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
|
||||
|
||||
const sql = `
|
||||
WITH params AS (
|
||||
SELECT
|
||||
$1::date AS start_date,
|
||||
$2::date AS end_date,
|
||||
$3::text AS brand,
|
||||
$4::text AS title_search,
|
||||
(($2::date - $1::date) + 1)::int AS days
|
||||
),
|
||||
category_path AS (
|
||||
WITH RECURSIVE cp AS (
|
||||
SELECT c.cat_id, c.name, c.parent_id, c.name::text AS path
|
||||
FROM categories c WHERE c.parent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT c.cat_id, c.name, c.parent_id, (cp.path || ' > ' || c.name)::text
|
||||
FROM categories c
|
||||
JOIN cp ON c.parent_id = cp.cat_id
|
||||
)
|
||||
SELECT * FROM cp
|
||||
),
|
||||
product_first_received AS (
|
||||
SELECT
|
||||
p.pid,
|
||||
COALESCE(p.first_received::date, MIN(r.received_date)::date) AS first_received_date
|
||||
FROM products p
|
||||
LEFT JOIN receivings r ON r.pid = p.pid
|
||||
GROUP BY p.pid, p.first_received
|
||||
),
|
||||
recent_products AS (
|
||||
SELECT p.pid
|
||||
FROM products p
|
||||
JOIN product_first_received fr ON fr.pid = p.pid
|
||||
JOIN params pr ON 1=1
|
||||
WHERE p.visible = true
|
||||
AND COALESCE(p.brand,'Unbranded') = pr.brand
|
||||
AND fr.first_received_date BETWEEN pr.start_date AND pr.end_date
|
||||
AND (pr.title_search IS NULL OR p.title ILIKE '%' || pr.title_search || '%')
|
||||
),
|
||||
product_pick_category AS (
|
||||
(
|
||||
SELECT DISTINCT ON (pc.pid)
|
||||
pc.pid,
|
||||
c.name AS category_name,
|
||||
COALESCE(cp.path, c.name) AS path
|
||||
FROM product_categories pc
|
||||
JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21))
|
||||
LEFT JOIN category_path cp ON cp.cat_id = c.cat_id
|
||||
WHERE pc.pid IN (SELECT pid FROM recent_products)
|
||||
AND (cp.path IS NULL OR (
|
||||
cp.path NOT ILIKE '%Black Friday%'
|
||||
AND cp.path NOT ILIKE '%Deals%'
|
||||
))
|
||||
AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals')
|
||||
ORDER BY pc.pid, length(COALESCE(cp.path,'')) DESC
|
||||
)
|
||||
UNION ALL
|
||||
(
|
||||
SELECT
|
||||
rp.pid,
|
||||
'Uncategorized'::text AS category_name,
|
||||
'Uncategorized'::text AS path
|
||||
FROM recent_products rp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM product_categories pc
|
||||
JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21))
|
||||
LEFT JOIN category_path cp ON cp.cat_id = c.cat_id
|
||||
WHERE pc.pid = rp.pid
|
||||
AND (cp.path IS NULL OR (
|
||||
cp.path NOT ILIKE '%Black Friday%'
|
||||
AND cp.path NOT ILIKE '%Deals%'
|
||||
))
|
||||
AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals')
|
||||
)
|
||||
)
|
||||
),
|
||||
product_sales AS (
|
||||
SELECT
|
||||
p.pid,
|
||||
p.title,
|
||||
p.sku,
|
||||
COALESCE(p.stock_quantity, 0) AS stock_quantity,
|
||||
COALESCE(p.price, 0) AS price,
|
||||
COALESCE(SUM(o.quantity), 0) AS total_sold
|
||||
FROM recent_products rp
|
||||
JOIN products p ON p.pid = rp.pid
|
||||
LEFT JOIN params pr ON true
|
||||
LEFT JOIN orders o ON o.pid = p.pid
|
||||
AND o.date::date BETWEEN pr.start_date AND pr.end_date
|
||||
AND (o.canceled IS DISTINCT FROM TRUE)
|
||||
GROUP BY p.pid, p.title, p.sku, p.stock_quantity, p.price
|
||||
)
|
||||
SELECT
|
||||
ppc.category_name,
|
||||
ppc.path,
|
||||
COUNT(ps.pid) AS num_products,
|
||||
SUM(ps.total_sold) AS total_sold,
|
||||
ROUND(AVG(COALESCE(ps.total_sold,0) / NULLIF(pr.days,0)), 2) AS avg_daily_sales,
|
||||
ROUND(AVG(COALESCE(ps.total_sold,0)), 2) AS avg_total_sold,
|
||||
MIN(ps.total_sold) AS min_total_sold,
|
||||
MAX(ps.total_sold) AS max_total_sold,
|
||||
JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'pid', ps.pid,
|
||||
'title', ps.title,
|
||||
'sku', ps.sku,
|
||||
'total_sold', ps.total_sold,
|
||||
'categoryPath', ppc.path
|
||||
)
|
||||
) AS products
|
||||
FROM product_sales ps
|
||||
JOIN product_pick_category ppc ON ppc.pid = ps.pid
|
||||
JOIN params pr ON true
|
||||
GROUP BY ppc.category_name, ppc.path
|
||||
HAVING SUM(ps.total_sold) >= 0
|
||||
ORDER BY (ppc.category_name = 'Uncategorized') ASC, avg_total_sold DESC NULLS LAST
|
||||
LIMIT 200;
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(sql, [startISO, endISO, brand, titleSearch]);
|
||||
|
||||
// Normalize/shape response keys to match front-end expectations
|
||||
const shaped = rows.map(r => ({
|
||||
category_name: r.category_name,
|
||||
path: r.path,
|
||||
avg_daily_sales: Number(r.avg_daily_sales) || 0,
|
||||
total_sold: Number(r.total_sold) || 0,
|
||||
num_products: Number(r.num_products) || 0,
|
||||
avgTotalSold: Number(r.avg_total_sold) || 0,
|
||||
minSold: Number(r.min_total_sold) || 0,
|
||||
maxSold: Number(r.max_total_sold) || 0,
|
||||
products: Array.isArray(r.products) ? r.products : []
|
||||
}));
|
||||
|
||||
res.json(shaped);
|
||||
} catch (error) {
|
||||
console.error('Error fetching forecast data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch forecast data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get overall analytics stats
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
@@ -608,4 +771,4 @@ router.get('/categories', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { format, addDays, addMonths } from "date-fns";
|
||||
import { Calendar as CalendarIcon, Info } from "lucide-react";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
interface DateRangePickerQuickProps {
|
||||
value: DateRange;
|
||||
onChange: (range: DateRange | undefined) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DateRangePickerQuick({ value, onChange, className }: DateRangePickerQuickProps) {
|
||||
return (
|
||||
<div className={cn("grid gap-2", className)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="date"
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"h-8 w-[300px] justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value?.from ? (
|
||||
value.to ? (
|
||||
<>
|
||||
{format(value.from, "LLL dd, y")} -{" "}
|
||||
{format(value.to, "LLL dd, y")}
|
||||
</>
|
||||
) : (
|
||||
format(value.from, "LLL dd, y")
|
||||
)
|
||||
) : (
|
||||
<span>Pick a date range</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-2" align="start">
|
||||
<div className="flex justify-center"><Badge variant="secondary"><Info className="mr-1 h-3 w-3" /> Only products received during the selected date range will be shown</Badge></div>
|
||||
<div className="space-y-2">
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={value?.from}
|
||||
selected={value}
|
||||
onSelect={(range) => {
|
||||
if (range) onChange(range);
|
||||
}}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -1), 1), to: new Date() })}>
|
||||
Last Month
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -3), 1), to: new Date() })}>
|
||||
Last 3 Months
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -6), 1), to: new Date() })}>
|
||||
Last 6 Months
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -12), 1), to: new Date() })}>
|
||||
Last Year
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,21 +8,17 @@ interface Product {
|
||||
pid: string;
|
||||
sku: string;
|
||||
title: string;
|
||||
stock_quantity: number;
|
||||
daily_sales_avg: number;
|
||||
forecast_units: number;
|
||||
forecast_revenue: number;
|
||||
confidence_level: number;
|
||||
total_sold: number;
|
||||
}
|
||||
|
||||
export interface ForecastItem {
|
||||
category: string;
|
||||
categoryPath: string;
|
||||
avgDailySales: number;
|
||||
totalSold: number;
|
||||
numProducts: number;
|
||||
avgPrice: number;
|
||||
avgTotalSold: number;
|
||||
minSold: number;
|
||||
maxSold: number;
|
||||
products?: Product[];
|
||||
}
|
||||
|
||||
@@ -57,7 +53,7 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "avgDailySales",
|
||||
accessorKey: "avgTotalSold",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -65,16 +61,54 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Avg Daily Sales
|
||||
Avg Total Sold
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("avgDailySales") as number;
|
||||
const value = row.getValue("avgTotalSold") as number;
|
||||
return value?.toFixed(2) || "0.00";
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "minSold",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Min Sold
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("minSold") as number;
|
||||
return value?.toLocaleString() || "0";
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "maxSold",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Max Sold
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("maxSold") as number;
|
||||
return value?.toLocaleString() || "0";
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "totalSold",
|
||||
header: ({ column }) => {
|
||||
@@ -112,44 +146,6 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
||||
return value?.toLocaleString() || "0";
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "avgTotalSold",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Avg Total Sold
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("avgTotalSold") as number;
|
||||
return value?.toFixed(2) || "0.00";
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "avgPrice",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Avg Price
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("avgPrice") as number;
|
||||
return `$${value?.toFixed(2) || "0.00"}`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const renderSubComponent = ({ row }: { row: any }) => {
|
||||
@@ -161,11 +157,7 @@ export const renderSubComponent = ({ row }: { row: any }) => {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Daily Sales</TableHead>
|
||||
<TableHead className="text-right">Forecast Units</TableHead>
|
||||
<TableHead className="text-right">Forecast Revenue</TableHead>
|
||||
<TableHead className="text-right">Confidence</TableHead>
|
||||
<TableHead className="text-right">Sold</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -182,15 +174,11 @@ export const renderSubComponent = ({ row }: { row: any }) => {
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||
<TableCell className="text-right">{product.daily_sales_avg.toFixed(1)}</TableCell>
|
||||
<TableCell className="text-right">{product.forecast_units.toFixed(1)}</TableCell>
|
||||
<TableCell className="text-right">{product.forecast_revenue.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{product.confidence_level.toFixed(1)}%</TableCell>
|
||||
<TableCell className="text-right">{product.total_sold?.toLocaleString?.() ?? product.total_sold}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -63,4 +63,4 @@ export function DateRangePicker({
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, Fragment } from "react";
|
||||
import { useState, useMemo, Fragment } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -16,16 +16,21 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
import { columns, ForecastItem, renderSubComponent } from "@/components/forecasting/columns";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { addDays } from "date-fns";
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker";
|
||||
import { addDays, addMonths } from "date-fns";
|
||||
import { DateRangePickerQuick } from "@/components/forecasting/DateRangePickerQuick";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
|
||||
export default function Forecasting() {
|
||||
const [selectedBrand, setSelectedBrand] = useState<string>("");
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
from: addDays(new Date(), -30),
|
||||
from: addDays(addMonths(new Date(), -1), 1),
|
||||
to: new Date(),
|
||||
});
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
|
||||
const handleDateRangeChange = (range: DateRange | undefined) => {
|
||||
if (range) {
|
||||
@@ -61,21 +66,16 @@ export default function Forecasting() {
|
||||
return data.map((item: any) => ({
|
||||
category: item.category_name,
|
||||
categoryPath: item.path,
|
||||
avgDailySales: Number(item.avg_daily_sales) || 0,
|
||||
totalSold: Number(item.total_sold) || 0,
|
||||
numProducts: Number(item.num_products) || 0,
|
||||
avgPrice: Number(item.avg_price) || 0,
|
||||
avgTotalSold: Number(item.avgTotalSold) || 0,
|
||||
minSold: Number(item.minSold) || 0,
|
||||
maxSold: Number(item.maxSold) || 0,
|
||||
products: item.products?.map((p: any) => ({
|
||||
pid: p.pid,
|
||||
title: p.title,
|
||||
sku: p.sku,
|
||||
stock_quantity: Number(p.stock_quantity) || 0,
|
||||
total_sold: Number(p.total_sold) || 0,
|
||||
daily_sales_avg: Number(p.daily_sales_avg) || 0,
|
||||
forecast_units: Number(p.forecast_units) || 0,
|
||||
forecast_revenue: Number(p.forecast_revenue) || 0,
|
||||
confidence_level: Number(p.confidence_level) || 0,
|
||||
categoryPath: item.path
|
||||
}))
|
||||
}));
|
||||
@@ -83,8 +83,60 @@ export default function Forecasting() {
|
||||
enabled: !!selectedBrand && !!dateRange.from && !!dateRange.to,
|
||||
});
|
||||
|
||||
// Local, instant filter + summary for title substring matches within category groups
|
||||
|
||||
type ProductLite = { pid: string; title: string; sku: string; total_sold: number; categoryPath: string };
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
if (!forecastData) return [] as ForecastItem[];
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term) return forecastData;
|
||||
|
||||
const filteredGroups: ForecastItem[] = [];
|
||||
const allMatchedProducts: ProductLite[] = [];
|
||||
for (const g of forecastData) {
|
||||
const matched: ProductLite[] = (g.products || []).filter((p: ProductLite) => p.title?.toLowerCase().includes(term));
|
||||
if (matched.length === 0) continue;
|
||||
const totalSold = matched.reduce((sum: number, p: ProductLite) => sum + (p.total_sold || 0), 0);
|
||||
const numProducts = matched.length;
|
||||
const avgTotalSold = numProducts > 0 ? totalSold / numProducts : 0;
|
||||
const minSold = matched.reduce((min: number, p: ProductLite) => Math.min(min, p.total_sold || 0), Number.POSITIVE_INFINITY);
|
||||
const maxSold = matched.reduce((max: number, p: ProductLite) => Math.max(max, p.total_sold || 0), 0);
|
||||
filteredGroups.push({
|
||||
category: g.category,
|
||||
categoryPath: g.categoryPath,
|
||||
totalSold,
|
||||
numProducts,
|
||||
avgTotalSold,
|
||||
minSold: Number.isFinite(minSold) ? minSold : 0,
|
||||
maxSold,
|
||||
products: matched,
|
||||
});
|
||||
allMatchedProducts.push(...matched);
|
||||
}
|
||||
|
||||
if (allMatchedProducts.length > 0) {
|
||||
const totalSoldAll = allMatchedProducts.reduce((sum: number, p: ProductLite) => sum + (p.total_sold || 0), 0);
|
||||
const avgTotalSoldAll = totalSoldAll / allMatchedProducts.length;
|
||||
const minSoldAll = allMatchedProducts.reduce((min: number, p: ProductLite) => Math.min(min, p.total_sold || 0), Number.POSITIVE_INFINITY);
|
||||
const maxSoldAll = allMatchedProducts.reduce((max: number, p: ProductLite) => Math.max(max, p.total_sold || 0), 0);
|
||||
filteredGroups.unshift({
|
||||
category: `Matches: "${search}"`,
|
||||
categoryPath: "",
|
||||
totalSold: totalSoldAll,
|
||||
numProducts: allMatchedProducts.length,
|
||||
avgTotalSold: avgTotalSoldAll,
|
||||
minSold: Number.isFinite(minSoldAll) ? minSoldAll : 0,
|
||||
maxSold: maxSoldAll,
|
||||
products: allMatchedProducts,
|
||||
});
|
||||
}
|
||||
|
||||
return filteredGroups;
|
||||
}, [forecastData, search]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: forecastData || [],
|
||||
data: displayData || [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
@@ -100,10 +152,10 @@ export default function Forecasting() {
|
||||
<div className="container mx-auto py-10">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Forecasting</CardTitle>
|
||||
<CardTitle>Historical Sales</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="flex gap-4 mb-6 items-center">
|
||||
<div className="w-[200px]">
|
||||
<Select value={selectedBrand} onValueChange={setSelectedBrand}>
|
||||
<SelectTrigger disabled={brandsLoading}>
|
||||
@@ -118,15 +170,36 @@ export default function Forecasting() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DateRangePicker
|
||||
<DateRangePickerQuick
|
||||
value={dateRange}
|
||||
onChange={handleDateRangeChange}
|
||||
/>
|
||||
{(Array.isArray(displayData) && displayData.length > 0) || search.trim().length > 0 ? (
|
||||
<div className="w-[400px] relative">
|
||||
<Input
|
||||
placeholder="Filter by product title"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pr-8"
|
||||
/>
|
||||
{search.trim().length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
||||
{forecastLoading ? (
|
||||
<div className="h-24 flex items-center justify-center">
|
||||
Loading forecast data...
|
||||
Loading sales data...
|
||||
</div>
|
||||
) : forecastData && (
|
||||
<div className="rounded-md border">
|
||||
@@ -153,6 +226,7 @@ export default function Forecasting() {
|
||||
<Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={String(row.original.category || '').startsWith('Matches:') ? 'bg-muted font-medium' : ''}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
@@ -187,4 +261,4 @@ export default function Forecasting() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user