Fix/enhance forecasting page
This commit is contained in:
@@ -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