Fix/enhance forecasting page
This commit is contained in:
@@ -1,6 +1,169 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
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
|
// Get overall analytics stats
|
||||||
router.get('/stats', async (req, res) => {
|
router.get('/stats', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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;
|
pid: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
title: string;
|
title: string;
|
||||||
stock_quantity: number;
|
total_sold: number;
|
||||||
daily_sales_avg: number;
|
|
||||||
forecast_units: number;
|
|
||||||
forecast_revenue: number;
|
|
||||||
confidence_level: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForecastItem {
|
export interface ForecastItem {
|
||||||
category: string;
|
category: string;
|
||||||
categoryPath: string;
|
categoryPath: string;
|
||||||
avgDailySales: number;
|
|
||||||
totalSold: number;
|
totalSold: number;
|
||||||
numProducts: number;
|
numProducts: number;
|
||||||
avgPrice: number;
|
|
||||||
avgTotalSold: number;
|
avgTotalSold: number;
|
||||||
|
minSold: number;
|
||||||
|
maxSold: number;
|
||||||
products?: Product[];
|
products?: Product[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +53,7 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "avgDailySales",
|
accessorKey: "avgTotalSold",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -65,16 +61,54 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
|||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Avg Daily Sales
|
Avg Total Sold
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const value = row.getValue("avgDailySales") as number;
|
const value = row.getValue("avgTotalSold") as number;
|
||||||
return value?.toFixed(2) || "0.00";
|
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",
|
accessorKey: "totalSold",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
@@ -112,44 +146,6 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
|||||||
return value?.toLocaleString() || "0";
|
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 }) => {
|
export const renderSubComponent = ({ row }: { row: any }) => {
|
||||||
@@ -161,11 +157,7 @@ export const renderSubComponent = ({ row }: { row: any }) => {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Product</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead className="text-right">Stock</TableHead>
|
<TableHead className="text-right">Sold</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>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -182,11 +174,7 @@ export const renderSubComponent = ({ row }: { row: any }) => {
|
|||||||
</a>
|
</a>
|
||||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
<TableCell className="text-right">{product.total_sold?.toLocaleString?.() ?? product.total_sold}</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>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -16,16 +16,21 @@ import {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { columns, ForecastItem, renderSubComponent } from "@/components/forecasting/columns";
|
import { columns, ForecastItem, renderSubComponent } from "@/components/forecasting/columns";
|
||||||
import { DateRange } from "react-day-picker";
|
import { DateRange } from "react-day-picker";
|
||||||
import { addDays } from "date-fns";
|
import { addDays, addMonths } from "date-fns";
|
||||||
import { DateRangePicker } from "@/components/ui/date-range-picker";
|
import { DateRangePickerQuick } from "@/components/forecasting/DateRangePickerQuick";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
|
||||||
export default function Forecasting() {
|
export default function Forecasting() {
|
||||||
const [selectedBrand, setSelectedBrand] = useState<string>("");
|
const [selectedBrand, setSelectedBrand] = useState<string>("");
|
||||||
const [dateRange, setDateRange] = useState<DateRange>({
|
const [dateRange, setDateRange] = useState<DateRange>({
|
||||||
from: addDays(new Date(), -30),
|
from: addDays(addMonths(new Date(), -1), 1),
|
||||||
to: new Date(),
|
to: new Date(),
|
||||||
});
|
});
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
|
||||||
|
|
||||||
const handleDateRangeChange = (range: DateRange | undefined) => {
|
const handleDateRangeChange = (range: DateRange | undefined) => {
|
||||||
if (range) {
|
if (range) {
|
||||||
@@ -61,21 +66,16 @@ export default function Forecasting() {
|
|||||||
return data.map((item: any) => ({
|
return data.map((item: any) => ({
|
||||||
category: item.category_name,
|
category: item.category_name,
|
||||||
categoryPath: item.path,
|
categoryPath: item.path,
|
||||||
avgDailySales: Number(item.avg_daily_sales) || 0,
|
|
||||||
totalSold: Number(item.total_sold) || 0,
|
totalSold: Number(item.total_sold) || 0,
|
||||||
numProducts: Number(item.num_products) || 0,
|
numProducts: Number(item.num_products) || 0,
|
||||||
avgPrice: Number(item.avg_price) || 0,
|
|
||||||
avgTotalSold: Number(item.avgTotalSold) || 0,
|
avgTotalSold: Number(item.avgTotalSold) || 0,
|
||||||
|
minSold: Number(item.minSold) || 0,
|
||||||
|
maxSold: Number(item.maxSold) || 0,
|
||||||
products: item.products?.map((p: any) => ({
|
products: item.products?.map((p: any) => ({
|
||||||
pid: p.pid,
|
pid: p.pid,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
sku: p.sku,
|
sku: p.sku,
|
||||||
stock_quantity: Number(p.stock_quantity) || 0,
|
|
||||||
total_sold: Number(p.total_sold) || 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
|
categoryPath: item.path
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
@@ -83,8 +83,60 @@ export default function Forecasting() {
|
|||||||
enabled: !!selectedBrand && !!dateRange.from && !!dateRange.to,
|
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({
|
const table = useReactTable({
|
||||||
data: forecastData || [],
|
data: displayData || [],
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
@@ -100,10 +152,10 @@ export default function Forecasting() {
|
|||||||
<div className="container mx-auto py-10">
|
<div className="container mx-auto py-10">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sales Forecasting</CardTitle>
|
<CardTitle>Historical Sales</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex gap-4 mb-6">
|
<div className="flex gap-4 mb-6 items-center">
|
||||||
<div className="w-[200px]">
|
<div className="w-[200px]">
|
||||||
<Select value={selectedBrand} onValueChange={setSelectedBrand}>
|
<Select value={selectedBrand} onValueChange={setSelectedBrand}>
|
||||||
<SelectTrigger disabled={brandsLoading}>
|
<SelectTrigger disabled={brandsLoading}>
|
||||||
@@ -118,15 +170,36 @@ export default function Forecasting() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<DateRangePicker
|
<DateRangePickerQuick
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={handleDateRangeChange}
|
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>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{forecastLoading ? (
|
{forecastLoading ? (
|
||||||
<div className="h-24 flex items-center justify-center">
|
<div className="h-24 flex items-center justify-center">
|
||||||
Loading forecast data...
|
Loading sales data...
|
||||||
</div>
|
</div>
|
||||||
) : forecastData && (
|
) : forecastData && (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
@@ -153,6 +226,7 @@ export default function Forecasting() {
|
|||||||
<Fragment key={row.id}>
|
<Fragment key={row.id}>
|
||||||
<TableRow
|
<TableRow
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className={String(row.original.category || '').startsWith('Matches:') ? 'bg-muted font-medium' : ''}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
|
|||||||
Reference in New Issue
Block a user