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 +}