Add forecasting page

This commit is contained in:
2025-01-15 22:08:52 -05:00
parent e5f97ab836
commit c8c3d323a4
15 changed files with 893 additions and 94 deletions

View File

@@ -11,6 +11,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2",
@@ -19,6 +20,7 @@
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
@@ -28,7 +30,9 @@
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@shadcn/ui": "^0.0.4",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.63.0",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.2",
"@tanstack/virtual-core": "^3.11.2",
"chart.js": "^4.4.7",
@@ -1243,6 +1247,37 @@
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.2.tgz",
"integrity": "sha512-b1oh54x4DMCdGsB4/7ahiSrViXxaBwRPotiZNnYXjLha9vfuURSAZErki6qjDoSIV0eXx5v57XnTGVtGwnfp2g==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-collection": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.4.tgz",
@@ -1829,6 +1864,37 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz",
"integrity": "sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz",
@@ -2512,6 +2578,32 @@
"node": ">=14"
}
},
"node_modules/@tabler/icons": {
"version": "3.28.1",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.28.1.tgz",
"integrity": "sha512-h7nqKEvFooLtFxhMOC1/2eiV+KRXhBUuDUUJrJlt6Ft6tuMw2eU/9GLQgrTk41DNmIEzp/LI83K9J9UUU8YBYQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.28.1",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.28.1.tgz",
"integrity": "sha512-KNBpA2kbxr3/2YK5swt7b/kd/xpDP1FHYZCxDFIw54tX8slELRFEf95VMxsccQHZeIcUbdoojmUUuYSbt/sM5Q==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.28.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.62.16",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.16.tgz",
@@ -2538,6 +2630,26 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.20.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
"integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.20.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz",
@@ -2555,6 +2667,19 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz",

View File

@@ -13,6 +13,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2",
@@ -21,6 +22,7 @@
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
@@ -30,7 +32,9 @@
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@shadcn/ui": "^0.0.4",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.63.0",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.2",
"@tanstack/virtual-core": "^3.11.2",
"chart.js": "^4.4.7",

View File

@@ -12,6 +12,7 @@ import { Login } from './pages/Login';
import { useEffect } from 'react';
import config from './config';
import { RequireAuth } from './components/auth/RequireAuth';
import Forecasting from "@/pages/Forecasting";
const queryClient = new QueryClient();
@@ -61,6 +62,7 @@ function App() {
<Route path="/purchase-orders" element={<PurchaseOrders />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/forecasting" element={<Forecasting />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>

View File

@@ -0,0 +1,170 @@
import { ColumnDef, Column } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { ArrowUpDown, ChevronDown, ChevronRight } from "lucide-react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
export type ProductDetail = {
product_id: string;
name: string;
sku: string;
stock_quantity: number;
total_sold: number;
avg_price: number;
};
export type ForecastItem = {
category: string;
avgDailySales: number;
totalSold: number;
numProducts: number;
avgPrice: number;
avgTotalSold: number;
products?: ProductDetail[];
};
export const columns: ColumnDef<ForecastItem>[] = [
{
id: "expander",
header: () => null,
cell: ({ row }) => {
return row.original.products?.length ? (
<Button
variant="ghost"
onClick={() => row.toggleExpanded()}
className="p-0 hover:bg-transparent"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
) : null;
},
},
{
accessorKey: "category",
header: ({ column }: { column: Column<ForecastItem> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Category
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
accessorKey: "avgDailySales",
header: ({ column }: { column: Column<ForecastItem> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Avg Daily Sales
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("avgDailySales") as number;
return value ? value.toFixed(2) : '0.00';
},
},
{
accessorKey: "totalSold",
header: ({ column }: { column: Column<ForecastItem> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Total Sold
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("totalSold") as number;
return value ? value.toLocaleString() : '0';
},
},
{
accessorKey: "avgTotalSold",
header: ({ column }: { column: Column<ForecastItem> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Avg Total Sold
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("avgTotalSold") as number;
return value ? value.toFixed(2) : '0.00';
},
},
{
accessorKey: "numProducts",
header: ({ column }: { column: Column<ForecastItem> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
# of Products
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
accessorKey: "avgPrice",
header: ({ column }: { column: Column<ForecastItem> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Avg Price
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("avgPrice") as number;
return value ? `$${value.toFixed(2)}` : '$0.00';
},
},
];
export const renderSubComponent = ({ row }: { row: any }) => {
const products = row.original.products || [];
return (
<div className="p-4">
<div className="grid grid-cols-6 gap-4 font-medium mb-2">
<div>Name</div>
<div>SKU</div>
<div>Stock</div>
<div>Total Sold</div>
<div>Avg Price</div>
</div>
{products.map((product: ProductDetail) => (
<div key={product.product_id} className="grid grid-cols-6 gap-4 py-2 border-t">
<div>{product.name}</div>
<div>{product.sku}</div>
<div>{product.stock_quantity}</div>
<div>{product.total_sold}</div>
<div>${product.avg_price.toFixed(2)}</div>
</div>
))}
</div>
);
};

View File

@@ -1,13 +1,13 @@
import {
Home,
Package,
ShoppingCart,
BarChart2,
Settings,
Box,
ClipboardList,
LogOut,
} from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react";
import {
Sidebar,
SidebarContent,
@@ -34,9 +34,9 @@ const items = [
url: "/products",
},
{
title: "Orders",
icon: ShoppingCart,
url: "/orders",
title: "Forecasting",
icon: IconCrystalBall,
url: "/forecasting",
},
{
title: "Purchase Orders",

View File

@@ -392,93 +392,6 @@ export function ProductFilters({
</ToggleGroup>
);
const renderNumberInput = () => (
<div className="flex flex-col gap-4 items-start">
<div className="mb-4">
<Button
variant="ghost"
onClick={handleBackToFilters}
className="text-muted-foreground"
>
Back to filters
</Button>
</div>
{renderOperatorSelect()}
<div className="flex items-center gap-2">
<Input
ref={numberInputRef}
type="number"
placeholder={`Enter ${selectedFilter?.label.toLowerCase()}`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (selectedOperator === "between") {
if (inputValue2) {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
if (!isNaN(val1) && !isNaN(val2)) {
handleApplyFilter([val1, val2]);
}
}
} else {
const val = parseFloat(inputValue);
if (!isNaN(val)) {
handleApplyFilter(val);
}
}
} else if (e.key === "Escape") {
e.stopPropagation();
handleBackToFilters();
}
}}
className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
{selectedOperator === "between" && (
<>
<span>and</span>
<Input
type="number"
placeholder={`Enter maximum`}
value={inputValue2}
onChange={(e) => setInputValue2(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
if (!isNaN(val1) && !isNaN(val2)) {
handleApplyFilter([val1, val2]);
}
} else if (e.key === "Escape") {
e.stopPropagation();
handleBackToFilters();
}
}}
className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</>
)}
<Button
onClick={() => {
if (selectedOperator === "between") {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
if (!isNaN(val1) && !isNaN(val2)) {
handleApplyFilter([val1, val2]);
}
} else {
const val = parseFloat(inputValue);
if (!isNaN(val)) {
handleApplyFilter(val);
}
}
}}
>
Apply
</Button>
</div>
</div>
);
const getFilterDisplayValue = (filter: ActiveFilter) => {
const filterValue = activeFilters[filter.id];

View File

@@ -0,0 +1,55 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,183 @@
import { useState } 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";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
Row,
Header,
HeaderGroup,
getExpandedRowModel,
} 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";
export default function Forecasting() {
const [selectedBrand, setSelectedBrand] = useState<string>("");
const [dateRange, setDateRange] = useState<DateRange>({
from: addDays(new Date(), -30),
to: new Date(),
});
const [sorting, setSorting] = useState<SortingState>([]);
const handleDateRangeChange = (range: DateRange | undefined) => {
if (range) {
setDateRange(range);
}
};
const { data: brands = [], isLoading: brandsLoading } = useQuery({
queryKey: ["brands"],
queryFn: async () => {
const response = await fetch("/api/products/brands");
if (!response.ok) {
throw new Error("Failed to fetch brands");
}
const data = await response.json();
return Array.isArray(data) ? data : [];
},
});
const { data: forecastData, isLoading: forecastLoading } = useQuery({
queryKey: ["forecast", selectedBrand, dateRange],
queryFn: async () => {
const params = new URLSearchParams({
brand: selectedBrand,
startDate: dateRange.from?.toISOString() || "",
endDate: dateRange.to?.toISOString() || "",
});
const response = await fetch(`/api/analytics/forecast?${params}`);
if (!response.ok) {
throw new Error("Failed to fetch forecast data");
}
const data = await response.json();
return data.map((item: any) => ({
category: item.category_name,
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,
products: item.products?.map((p: any) => ({
product_id: p.product_id,
name: p.product_name,
sku: p.sku,
stock_quantity: Number(p.stock_quantity) || 0,
total_sold: Number(p.total_sold) || 0,
avg_price: Number(p.avg_price) || 0,
}))
}));
},
enabled: !!selectedBrand && !!dateRange.from && !!dateRange.to,
});
const table = useReactTable({
data: forecastData || [],
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
onSortingChange: setSorting,
state: {
sorting,
},
getRowCanExpand: () => true,
});
return (
<div className="container mx-auto py-10">
<Card>
<CardHeader>
<CardTitle>Sales Forecasting</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-4 mb-6">
<div className="w-[200px]">
<Select value={selectedBrand} onValueChange={setSelectedBrand}>
<SelectTrigger disabled={brandsLoading}>
<SelectValue placeholder="Select brand" />
</SelectTrigger>
<SelectContent>
{brands.map((brand: string) => (
<SelectItem key={brand} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DateRangePicker
value={dateRange}
onChange={handleDateRangeChange}
/>
</div>
{forecastLoading ? (
<div className="h-24 flex items-center justify-center">
Loading forecast data...
</div>
) : forecastData && (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup: HeaderGroup<ForecastItem>) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header: Header<ForecastItem, unknown>) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row: Row<ForecastItem>) => (
<>
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={columns.length}>
{renderSubComponent({ row })}
</TableCell>
</TableRow>
)}
</>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
interface Product {
product_id: string;
name: string;
sku: string;
stock_quantity: number;
total_sold: number;
avg_price: number;
}
interface CategoryMetrics {
category_id: string;
category_name: string;
brand: string;
num_products: number;
avg_daily_sales: number;
total_sold: number;
avgTotalSold: number;
avg_price: number;
products: Product[];
}
{data && (
<ScrollArea className="h-[600px] w-full rounded-md border p-4">
<Accordion type="single" collapsible className="w-full">
{data.map((category: CategoryMetrics, index: number) => (
<AccordionItem key={category.category_id} value={category.category_id}>
<AccordionTrigger className="hover:no-underline">
<div className="grid grid-cols-6 w-full text-sm">
<div className="text-left font-medium">{category.category_name}</div>
<div className="text-right">{category.num_products}</div>
<div className="text-right">{category.avg_daily_sales.toFixed(2)}</div>
<div className="text-right">{category.total_sold}</div>
<div className="text-right">${category.avg_price.toFixed(2)}</div>
<div className="text-right">{category.avgTotalSold.toFixed(2)}</div>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
<div className="grid grid-cols-5 text-sm font-medium text-muted-foreground mb-2">
<div>Product</div>
<div className="text-right">SKU</div>
<div className="text-right">Stock</div>
<div className="text-right">Total Sold</div>
<div className="text-right">Avg Price</div>
</div>
{JSON.parse(category.products).map((product: Product) => (
<div key={product.product_id} className="grid grid-cols-5 text-sm">
<div className="truncate">{product.name}</div>
<div className="text-right">{product.sku}</div>
<div className="text-right">{product.stock_quantity}</div>
<div className="text-right">{product.total_sold}</div>
<div className="text-right">${product.avg_price.toFixed(2)}</div>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</ScrollArea>
)}