diff --git a/inventory-server/src/routes/analytics.js b/inventory-server/src/routes/analytics.js
index 5ef7faf..c36ec62 100644
--- a/inventory-server/src/routes/analytics.js
+++ b/inventory-server/src/routes/analytics.js
@@ -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;
\ No newline at end of file
+module.exports = router;
diff --git a/inventory/src/components/forecasting/DateRangePickerQuick.tsx b/inventory/src/components/forecasting/DateRangePickerQuick.tsx
new file mode 100644
index 0000000..dbb36d1
--- /dev/null
+++ b/inventory/src/components/forecasting/DateRangePickerQuick.tsx
@@ -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 (
+
+
+
+
+
+
+ Only products received during the selected date range will be shown
+
+
{
+ if (range) onChange(range);
+ }}
+ numberOfMonths={2}
+ />
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/inventory/src/components/forecasting/columns.tsx b/inventory/src/components/forecasting/columns.tsx
index 054af27..288ec2c 100644
--- a/inventory/src/components/forecasting/columns.tsx
+++ b/inventory/src/components/forecasting/columns.tsx
@@ -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[] = [
),
},
{
- accessorKey: "avgDailySales",
+ accessorKey: "avgTotalSold",
header: ({ column }) => {
return (
);
},
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 (
+
+ );
+ },
+ cell: ({ row }) => {
+ const value = row.getValue("minSold") as number;
+ return value?.toLocaleString() || "0";
+ },
+ },
+ {
+ accessorKey: "maxSold",
+ header: ({ column }) => {
+ return (
+
+ );
+ },
+ 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[] = [
return value?.toLocaleString() || "0";
},
},
- {
- accessorKey: "avgTotalSold",
- header: ({ column }) => {
- return (
-
- );
- },
- cell: ({ row }) => {
- const value = row.getValue("avgTotalSold") as number;
- return value?.toFixed(2) || "0.00";
- },
- },
- {
- accessorKey: "avgPrice",
- header: ({ column }) => {
- return (
-
- );
- },
- 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 }) => {
Product
- Stock
- Daily Sales
- Forecast Units
- Forecast Revenue
- Confidence
+ Sold
@@ -182,15 +174,11 @@ export const renderSubComponent = ({ row }: { row: any }) => {
{product.sku}
- {product.stock_quantity}
- {product.daily_sales_avg.toFixed(1)}
- {product.forecast_units.toFixed(1)}
- {product.forecast_revenue.toFixed(2)}
- {product.confidence_level.toFixed(1)}%
+ {product.total_sold?.toLocaleString?.() ?? product.total_sold}
))}
);
-};
\ No newline at end of file
+};
diff --git a/inventory/src/components/ui/date-range-picker.tsx b/inventory/src/components/ui/date-range-picker.tsx
index 58b3b78..7c3a112 100644
--- a/inventory/src/components/ui/date-range-picker.tsx
+++ b/inventory/src/components/ui/date-range-picker.tsx
@@ -63,4 +63,4 @@ export function DateRangePicker({
);
-}
\ No newline at end of file
+}
diff --git a/inventory/src/pages/Forecasting.tsx b/inventory/src/pages/Forecasting.tsx
index f956a92..02cc3cb 100644
--- a/inventory/src/pages/Forecasting.tsx
+++ b/inventory/src/pages/Forecasting.tsx
@@ -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("");
const [dateRange, setDateRange] = useState({
- from: addDays(new Date(), -30),
+ from: addDays(addMonths(new Date(), -1), 1),
to: new Date(),
});
const [sorting, setSorting] = useState([]);
+ const [search, setSearch] = useState("");
+
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() {
- Sales Forecasting
+ Historical Sales
-
+
-
+ {(Array.isArray(displayData) && displayData.length > 0) || search.trim().length > 0 ? (
+
+ setSearch(e.target.value)}
+ className="pr-8"
+ />
+ {search.trim().length > 0 && (
+
+ )}
+
+ ) : null}
+
{forecastLoading ? (
- Loading forecast data...
+ Loading sales data...
) : forecastData && (
@@ -153,6 +226,7 @@ export default function Forecasting() {
{row.getVisibleCells().map((cell) => (
@@ -187,4 +261,4 @@ export default function Forecasting() {
);
-}
\ No newline at end of file
+}