Add predictions and forecasting analytics

Introduces a new PredictionsChart component and integrates it into the AnalyticsDashboard with a new tab for AI-powered sales, demand, and stock forecasting. Expands analytics-service with types and service functions for predictions, enabling comprehensive future insights for revenue, demand, and inventory.
This commit is contained in:
g
2026-01-10 01:47:38 +00:00
parent 1cad96887e
commit c69367e6da
3 changed files with 642 additions and 1 deletions

View File

@@ -0,0 +1,428 @@
"use client";
import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
TrendingUp,
TrendingDown,
Package,
DollarSign,
AlertTriangle,
RefreshCw,
Calendar,
BarChart3,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { Skeleton } from "@/components/ui/skeleton";
import {
getPredictionsOverviewWithStore,
getStockPredictionsWithStore,
type PredictionsOverview,
type StockPredictionsResponse,
} from "@/lib/services/analytics-service";
import { formatGBP } from "@/utils/format";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { format } from "date-fns";
interface PredictionsChartProps {
timeRange?: number;
}
export default function PredictionsChart({
timeRange = 30,
}: PredictionsChartProps) {
const [predictions, setPredictions] = useState<PredictionsOverview | null>(
null,
);
const [stockPredictions, setStockPredictions] =
useState<StockPredictionsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [daysAhead, setDaysAhead] = useState(7);
const [activeTab, setActiveTab] = useState<"overview" | "stock">("overview");
const { toast } = useToast();
const fetchPredictions = async () => {
try {
setLoading(true);
const [overview, stock] = await Promise.all([
getPredictionsOverviewWithStore(daysAhead, timeRange),
getStockPredictionsWithStore(timeRange),
]);
setPredictions(overview);
setStockPredictions(stock);
} catch (error) {
console.error("Error fetching predictions:", error);
toast({
title: "Error",
description: "Failed to load predictions",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPredictions();
}, [daysAhead, timeRange]);
const getConfidenceColor = (confidence: string) => {
switch (confidence) {
case "high":
return "bg-green-500/10 text-green-700 dark:text-green-400";
case "medium":
return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400";
case "low":
return "bg-red-500/10 text-red-700 dark:text-red-400";
default:
return "bg-gray-500/10 text-gray-700 dark:text-gray-400";
}
};
if (loading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
</CardHeader>
<CardContent>
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</CardContent>
</Card>
);
}
if (!predictions) {
return (
<Card>
<CardHeader>
<CardTitle>Predictions</CardTitle>
<CardDescription>
Forecast future sales, demand, and stock levels
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center py-8">
No prediction data available. Need more historical data.
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Predictions & Forecasting
</CardTitle>
<CardDescription>
AI-powered predictions for sales, demand, and inventory
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Select
value={daysAhead.toString()}
onValueChange={(value) => setDaysAhead(parseInt(value))}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">7 days</SelectItem>
<SelectItem value="14">14 days</SelectItem>
<SelectItem value="30">30 days</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={fetchPredictions}
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-2 mb-6">
<Button
variant={activeTab === "overview" ? "default" : "outline"}
onClick={() => setActiveTab("overview")}
size="sm"
>
Overview
</Button>
<Button
variant={activeTab === "stock" ? "default" : "outline"}
onClick={() => setActiveTab("stock")}
size="sm"
>
Stock Predictions
</Button>
</div>
{activeTab === "overview" && (
<div className="space-y-6">
{/* Sales Predictions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<DollarSign className="h-4 w-4" />
Revenue Prediction
</CardTitle>
</CardHeader>
<CardContent>
{predictions.sales.predicted !== null ? (
<div className="space-y-2">
<div className="text-2xl font-bold">
{formatGBP(predictions.sales.predicted)}
</div>
<div className="flex items-center gap-2">
<Badge
className={getConfidenceColor(
predictions.sales.confidence,
)}
>
{predictions.sales.confidence} confidence
</Badge>
<span className="text-xs text-muted-foreground">
Next {daysAhead} days
</span>
</div>
{predictions.sales.predictedOrders && (
<div className="text-sm text-muted-foreground">
~{Math.round(predictions.sales.predictedOrders)}{" "}
orders
</div>
)}
{predictions.sales.minPrediction &&
predictions.sales.maxPrediction && (
<div className="text-xs text-muted-foreground">
Range: {formatGBP(predictions.sales.minPrediction)} -{" "}
{formatGBP(predictions.sales.maxPrediction)}
</div>
)}
</div>
) : (
<div className="text-sm text-muted-foreground">
{predictions.sales.message ||
"Insufficient data for prediction"}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Package className="h-4 w-4" />
Demand Prediction
</CardTitle>
</CardHeader>
<CardContent>
{predictions.demand.predictedDaily !== null ? (
<div className="space-y-2">
<div className="text-2xl font-bold">
{predictions.demand.predictedDaily.toFixed(1)} units/day
</div>
<div className="flex items-center gap-2">
<Badge
className={getConfidenceColor(
predictions.demand.confidence,
)}
>
{predictions.demand.confidence} confidence
</Badge>
</div>
{predictions.demand.predictedWeekly && (
<div className="text-sm text-muted-foreground">
~{predictions.demand.predictedWeekly.toFixed(0)} units/week
</div>
)}
{predictions.demand.predictedMonthly && (
<div className="text-sm text-muted-foreground">
~{predictions.demand.predictedMonthly.toFixed(0)} units/month
</div>
)}
</div>
) : (
<div className="text-sm text-muted-foreground">
{predictions.demand.message ||
"Insufficient data for prediction"}
</div>
)}
</CardContent>
</Card>
</div>
{/* Daily Predictions Chart */}
{predictions.sales.dailyPredictions &&
predictions.sales.dailyPredictions.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">
Daily Revenue Forecast
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{predictions.sales.dailyPredictions.map((day) => (
<div
key={day.day}
className="flex items-center justify-between p-2 rounded-lg border"
>
<div className="flex items-center gap-3">
<Calendar className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm font-medium">
Day {day.day}
</div>
<div className="text-xs text-muted-foreground">
{format(new Date(day.date), "MMM d, yyyy")}
</div>
</div>
</div>
<div className="text-sm font-semibold">
{formatGBP(day.predicted)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)}
{activeTab === "stock" && (
<div className="space-y-4">
{stockPredictions && stockPredictions.predictions.length > 0 ? (
<>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
{stockPredictions.totalProducts} products tracked
</p>
{stockPredictions.productsNeedingRestock > 0 && (
<p className="text-sm font-medium text-orange-600 dark:text-orange-400 mt-1">
{stockPredictions.productsNeedingRestock} products
need restocking soon
</p>
)}
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Current Stock</TableHead>
<TableHead>Days Until Out</TableHead>
<TableHead>Estimated Date</TableHead>
<TableHead>Confidence</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stockPredictions.predictions.map((prediction) => (
<TableRow key={prediction.productId}>
<TableCell className="font-medium">
{prediction.productName}
</TableCell>
<TableCell>
{prediction.currentStock} {prediction.unitType}
</TableCell>
<TableCell>
{prediction.prediction.daysUntilOutOfStock !== null
? `${prediction.prediction.daysUntilOutOfStock} days`
: "N/A"}
</TableCell>
<TableCell>
{prediction.prediction.estimatedDate
? format(
new Date(
prediction.prediction.estimatedDate,
),
"MMM d, yyyy",
)
: "N/A"}
</TableCell>
<TableCell>
<Badge
className={getConfidenceColor(
prediction.prediction.confidence,
)}
>
{prediction.prediction.confidence}
</Badge>
</TableCell>
<TableCell>
{prediction.needsRestock ? (
<Badge
variant="destructive"
className="flex items-center gap-1 w-fit"
>
<AlertTriangle className="h-3 w-3" />
Restock Soon
</Badge>
) : (
<Badge variant="outline">OK</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
) : (
<div className="text-center py-8 text-muted-foreground">
<Package className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No stock predictions available</p>
<p className="text-sm mt-1">
Enable stock tracking on products to get predictions
</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}