All checks were successful
Build Frontend / build (push) Successful in 1m6s
Extended analytics dashboards and charts to support a 180-day time range selection. Also updated tooltip position in PredictionsChart for improved UI consistency.
928 lines
41 KiB
TypeScript
928 lines
41 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, memo, useMemo, useCallback } 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,
|
|
Brain,
|
|
Layers,
|
|
Zap,
|
|
Info,
|
|
Download,
|
|
} from "lucide-react";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import CountUp from "react-countup";
|
|
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";
|
|
import {
|
|
AreaChart,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip as RechartsTooltip,
|
|
ResponsiveContainer,
|
|
} from "recharts";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import { Slider } from "@/components/ui/slider";
|
|
|
|
interface PredictionsChartProps {
|
|
timeRange?: number;
|
|
}
|
|
|
|
export default function PredictionsChart({
|
|
timeRange = 90,
|
|
}: PredictionsChartProps) {
|
|
const [predictions, setPredictions] = useState<PredictionsOverview | null>(
|
|
null,
|
|
);
|
|
const [baselinePredictions, setBaselinePredictions] = useState<PredictionsOverview | null>(
|
|
null,
|
|
);
|
|
// Batch data holds all pre-cached predictions for instant switching
|
|
const [batchData, setBatchData] = useState<{
|
|
[horizon: string]: {
|
|
[simulationFactor: string]: PredictionsOverview;
|
|
};
|
|
} | null>(null);
|
|
const [stockPredictions, setStockPredictions] =
|
|
useState<StockPredictionsResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isSimulating, setIsSimulating] = useState(false);
|
|
const [daysAhead, setDaysAhead] = useState(7);
|
|
const [activeTab, setActiveTab] = useState<"overview" | "stock">("overview");
|
|
const [simulationFactor, setSimulationFactor] = useState(0);
|
|
const [committedSimulationFactor, setCommittedSimulationFactor] = useState(0);
|
|
const { toast } = useToast();
|
|
|
|
// Fetch all predictions in batch (for instant client-side switching)
|
|
const fetchBatchData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const { getBatchPredictionsWithStore } = await import("@/lib/services/analytics-service");
|
|
const [batchResponse, stock] = await Promise.all([
|
|
getBatchPredictionsWithStore(timeRange),
|
|
getStockPredictionsWithStore(timeRange),
|
|
]);
|
|
|
|
if (batchResponse.success && batchResponse.predictions) {
|
|
setBatchData(batchResponse.predictions);
|
|
// Set initial predictions from batch
|
|
const horizonData = batchResponse.predictions[daysAhead.toString()];
|
|
if (horizonData) {
|
|
const baseline = horizonData["0"];
|
|
if (baseline) {
|
|
setBaselinePredictions(baseline);
|
|
setPredictions(baseline);
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback to single request if batch not available
|
|
const overview = await getPredictionsOverviewWithStore(daysAhead, timeRange, 0);
|
|
setBaselinePredictions(overview);
|
|
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);
|
|
}
|
|
};
|
|
|
|
// Switch predictions from batch data (no API call!)
|
|
const switchPredictions = useCallback((horizon: number, simFactor: number) => {
|
|
if (!batchData) return;
|
|
|
|
const horizonData = batchData[horizon.toString()];
|
|
if (!horizonData) return;
|
|
|
|
// Simulation factor is stored as decimal (e.g., 0.1 for 10%)
|
|
const simKey = (simFactor / 100).toString();
|
|
const newPrediction = horizonData[simKey];
|
|
|
|
if (newPrediction) {
|
|
setPredictions(newPrediction);
|
|
if (simFactor === 0) {
|
|
setBaselinePredictions(newPrediction);
|
|
}
|
|
}
|
|
}, [batchData]);
|
|
|
|
// Fetch batch data on initial load or when timeRange changes
|
|
useEffect(() => {
|
|
fetchBatchData();
|
|
setCommittedSimulationFactor(0);
|
|
setSimulationFactor(0);
|
|
}, [timeRange]);
|
|
|
|
// Auto-adjust daysAhead if it exceeds historical timeRange
|
|
useEffect(() => {
|
|
if (daysAhead > timeRange) {
|
|
setDaysAhead(timeRange);
|
|
}
|
|
}, [timeRange, daysAhead]);
|
|
|
|
// Switch predictions when daysAhead changes (instant, from batch)
|
|
useEffect(() => {
|
|
if (batchData) {
|
|
switchPredictions(daysAhead, committedSimulationFactor);
|
|
}
|
|
}, [daysAhead, batchData, switchPredictions]);
|
|
|
|
// Switch predictions when simulation factor changes (instant, from batch)
|
|
useEffect(() => {
|
|
if (batchData) {
|
|
switchPredictions(daysAhead, committedSimulationFactor);
|
|
}
|
|
}, [committedSimulationFactor, batchData, switchPredictions]);
|
|
|
|
|
|
const getConfidenceColor = (confidence: string) => {
|
|
switch (confidence) {
|
|
case "very_high":
|
|
return "bg-emerald-600/20 text-emerald-700 dark:text-emerald-400 border-emerald-600/30";
|
|
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";
|
|
}
|
|
};
|
|
|
|
const getConfidenceLabel = (confidence: string) => {
|
|
switch (confidence) {
|
|
case "very_high":
|
|
return "Very High";
|
|
case "high":
|
|
return "High";
|
|
case "medium":
|
|
return "Medium";
|
|
case "low":
|
|
return "Low";
|
|
default:
|
|
return confidence;
|
|
}
|
|
};
|
|
|
|
// Combine baseline and simulated data for overlay chart
|
|
const chartData = useMemo(() => {
|
|
if (!predictions?.sales?.dailyPredictions) return [];
|
|
|
|
const baselineData = baselinePredictions?.sales?.dailyPredictions || [];
|
|
const simulatedDailyData = predictions?.sales?.dailyPredictions || [];
|
|
|
|
return simulatedDailyData.map((d: any, idx: number) => ({
|
|
...d,
|
|
formattedDate: format(new Date(d.date), "MMM d"),
|
|
simulated: d.predicted,
|
|
baseline: baselineData[idx]?.predicted ?? d.predicted,
|
|
orders: d.predictedOrders || 0,
|
|
}));
|
|
}, [predictions, baselinePredictions]);
|
|
|
|
// Keep simulatedData for export compatibility
|
|
const simulatedData = chartData;
|
|
|
|
const handleExportCSV = () => {
|
|
if (!simulatedData.length) return;
|
|
|
|
// Create CSV headers
|
|
const headers = ["Date", "Baseline Revenue", "Simulated Revenue", "Orders", "Confidence"];
|
|
|
|
// Create CSV rows
|
|
const rows = simulatedData.map(d => [
|
|
format(new Date(d.date), "yyyy-MM-dd"),
|
|
d.baseline?.toFixed(2) || "",
|
|
d.simulated?.toFixed(2) || "",
|
|
d.orders || "",
|
|
predictions?.sales?.confidence || "unknown"
|
|
]);
|
|
|
|
// Combine headers and rows
|
|
const csvContent = [
|
|
headers.join(","),
|
|
...rows.map(row => row.join(","))
|
|
].join("\n");
|
|
|
|
// Create download link
|
|
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.setAttribute("href", url);
|
|
link.setAttribute("download", `predictions_export_${format(new Date(), "yyyyMMdd")}.csv`);
|
|
link.style.visibility = "hidden";
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
};
|
|
|
|
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>
|
|
{predictions?.sales?.aiModel?.used
|
|
? "AI neural network + statistical models for sales, demand, and inventory"
|
|
: "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" disabled={timeRange < 14}>
|
|
14 days {timeRange < 14 && "(Needs 14d history)"}
|
|
</SelectItem>
|
|
<SelectItem value="30" disabled={timeRange < 30}>
|
|
30 days {timeRange < 30 && "(Needs 30d history)"}
|
|
</SelectItem>
|
|
<SelectItem value="60" disabled={timeRange < 60}>
|
|
60 days {timeRange < 60 && "(Needs 60d history)"}
|
|
</SelectItem>
|
|
<SelectItem value="90" disabled={timeRange < 90}>
|
|
90 days {timeRange < 90 && "(Needs 90d history)"}
|
|
</SelectItem>
|
|
<SelectItem value="180" disabled={timeRange < 180}>
|
|
180 days {timeRange < 180 && "(Needs 180d history)"}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={fetchBatchData}
|
|
disabled={loading || isSimulating}
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 ${loading || isSimulating ? "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">
|
|
<TooltipProvider delayDuration={0}>
|
|
<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 && predictions?.sales?.predicted !== undefined ? (
|
|
<div className="space-y-2">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="text-2xl font-bold w-fit cursor-help">
|
|
<CountUp
|
|
end={predictions?.sales?.predicted || 0}
|
|
duration={1.5}
|
|
separator=","
|
|
decimals={2}
|
|
prefix="£"
|
|
/>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="z-[100]">
|
|
<p>Predicted daily average revenue for the next {daysAhead} days</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex cursor-help">
|
|
<Badge
|
|
className={getConfidenceColor(
|
|
predictions?.sales?.confidence || "low",
|
|
)}
|
|
>
|
|
{getConfidenceLabel(predictions?.sales?.confidence || "low")} Confidence
|
|
{predictions?.sales?.confidenceScore !== undefined && (
|
|
<span className="ml-1 opacity-75">
|
|
({Math.round((predictions?.sales?.confidenceScore || 0) * 100)}%)
|
|
</span>
|
|
)}
|
|
</Badge>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="z-[100]">
|
|
<p>Based on data consistency, historical accuracy, and model agreement</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{predictions?.sales?.aiModel?.used && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex cursor-help">
|
|
<Badge variant="outline" className="bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/30">
|
|
🤖 AI Powered
|
|
{predictions?.sales?.aiModel?.modelAccuracy !== undefined && (
|
|
<span className="ml-1 opacity-75">
|
|
({Math.round((predictions?.sales?.aiModel?.modelAccuracy || 0) * 100)}%)
|
|
</span>
|
|
)}
|
|
</Badge>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="z-[100]">
|
|
<p>Predictions generated using a Deep Learning Ensemble Model</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{predictions?.sales?.trend && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex cursor-help">
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
predictions?.sales?.trend?.direction === "up"
|
|
? "text-green-600 border-green-600"
|
|
: predictions?.sales?.trend?.direction === "down"
|
|
? "text-red-600 border-red-600"
|
|
: ""
|
|
}
|
|
>
|
|
{predictions?.sales?.trend?.direction === "up" && (
|
|
<TrendingUp className="h-3 w-3 mr-1" />
|
|
)}
|
|
{predictions?.sales?.trend?.direction === "down" && (
|
|
<TrendingDown className="h-3 w-3 mr-1" />
|
|
)}
|
|
{predictions?.sales?.trend?.direction === "up"
|
|
? "Trending Up"
|
|
: predictions?.sales?.trend?.direction === "down"
|
|
? "Trending Down"
|
|
: "Stable"}
|
|
</Badge>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="z-[100]">
|
|
<p>Direction of the recent sales trend (slope analysis)</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<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 || 0)}{" "}
|
|
orders
|
|
</div>
|
|
)}
|
|
{!predictions?.sales?.confidenceIntervals &&
|
|
predictions?.sales?.minPrediction &&
|
|
predictions?.sales?.maxPrediction && (
|
|
<div className="text-xs text-muted-foreground">
|
|
Range: {formatGBP(predictions?.sales?.minPrediction || 0)} -{" "}
|
|
{formatGBP(predictions?.sales?.maxPrediction || 0)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-muted-foreground">
|
|
{predictions?.sales?.message ||
|
|
"Insufficient data for prediction"}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Model Intelligence Card */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
<Brain className="h-4 w-4" />
|
|
Model Intelligence
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="z-[100]">
|
|
<p>Technical details about the active prediction model</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-muted-foreground">Architecture</span>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="text-sm font-medium flex items-center gap-1 cursor-help">
|
|
<Layers className="h-3 w-3 text-purple-500" />
|
|
Hybrid Ensemble (Deep Learning)
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="z-[100]">
|
|
<p>Combines LSTM Neural Networks with Statistical Methods (Holt-Winters, ARIMA)</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
{stockPredictions?.predictions && (
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-muted-foreground">Features</span>
|
|
<span className="text-xs font-medium bg-secondary px-2 py-1 rounded-md">
|
|
Multi-Feature Enabled
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-muted-foreground">Optimization</span>
|
|
<span className="text-sm font-medium flex items-center gap-1">
|
|
<Zap className="h-3 w-3 text-amber-500" />
|
|
Performance Tuned
|
|
</span>
|
|
</div>
|
|
<div className="pt-2 border-t text-xs text-muted-foreground">
|
|
Model automatically retrains with new sales data.
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TooltipProvider>
|
|
</div>
|
|
|
|
<Alert className="bg-yellow-500/10 border-yellow-500/30 text-yellow-700 dark:text-yellow-400">
|
|
<AlertTriangle className="h-4 w-4 stroke-yellow-700 dark:stroke-yellow-400" />
|
|
<AlertTitle>Prediction Accuracy Warning</AlertTitle>
|
|
<AlertDescription>
|
|
These predictions are estimates based on historical sales data. Actual results may vary due to external factors, market conditions, and unforeseen events. Use these insights as a guide, not a guarantee.
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
{/* Daily Predictions Chart */}
|
|
{predictions?.sales?.dailyPredictions &&
|
|
predictions?.sales?.dailyPredictions.length > 0 && (
|
|
<Card className="glass-morphism border-primary/10 overflow-hidden">
|
|
<CardHeader className="pb-6 bg-muted/5">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div>
|
|
<CardTitle className="text-xl font-bold flex items-center gap-2 tracking-tight">
|
|
<Zap className="h-5 w-5 text-amber-500 fill-amber-500/20" />
|
|
Scenario Lab
|
|
</CardTitle>
|
|
<CardDescription className="text-muted-foreground/80 font-medium">
|
|
Adjust variables to see how traffic shifts impact your bottom line.
|
|
</CardDescription>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 bg-black/40 p-2.5 rounded-2xl border border-white/5 shadow-2xl backdrop-blur-md">
|
|
<div className="flex flex-col items-start min-w-[150px]">
|
|
<div className="flex items-center gap-1.5 mb-1 ml-1">
|
|
<span className="text-[10px] font-bold uppercase tracking-wider text-primary/40">
|
|
Traffic Simulation
|
|
</span>
|
|
<TooltipProvider delayDuration={0}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3 w-3 text-primary/30 cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="max-w-[200px] z-[110] bg-black border-white/10 text-white p-2">
|
|
<p className="text-[11px] leading-relaxed">
|
|
Simulate traffic growth or decline to see how it might impact your future revenue and order volume.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
<div className="flex items-center gap-3 w-full">
|
|
<Slider
|
|
value={[simulationFactor]}
|
|
min={-50}
|
|
max={50}
|
|
step={10}
|
|
onValueChange={(val) => setSimulationFactor(val[0])}
|
|
onValueCommit={(val) => setCommittedSimulationFactor(val[0])}
|
|
className="w-full flex-1"
|
|
/>
|
|
<Badge variant="outline" className={`ml-2 min-w-[50px] text-center font-bold border-2 ${simulationFactor > 0 ? "text-emerald-400 border-emerald-500/30 bg-emerald-500/10" : simulationFactor < 0 ? "text-rose-400 border-rose-500/30 bg-rose-500/10" : "text-primary/60"}`}>
|
|
{simulationFactor > 0 ? "+" : ""}{simulationFactor}%
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
{(simulationFactor !== 0 || committedSimulationFactor !== 0) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-9 w-9 hover:bg-white/10 rounded-xl transition-all"
|
|
onClick={() => {
|
|
setSimulationFactor(0);
|
|
setCommittedSimulationFactor(0);
|
|
}}
|
|
title="Reset Scenario"
|
|
>
|
|
<RefreshCw className="h-4 w-4 text-primary/70" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<Button variant="outline" size="sm" onClick={handleExportCSV} className="rounded-xl border-white/10 hover:bg-white/5 font-bold px-4">
|
|
<Download className="mr-2 h-4 w-4" />
|
|
Export Forecast
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-8">
|
|
{/* Legend / Key */}
|
|
<div className="flex items-center gap-8 mb-8 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-2.5 h-2.5 rounded-full bg-[#8884d8]" />
|
|
Baseline Forecast
|
|
</div>
|
|
{committedSimulationFactor !== 0 && (
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-2.5 h-2.5 rounded-full bg-[#10b981]" />
|
|
Simulated Scenario
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="h-80 w-full relative">
|
|
{isSimulating && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm z-20 transition-all rounded-xl">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="relative">
|
|
<RefreshCw className="h-10 w-10 animate-spin text-primary" />
|
|
<Zap className="h-4 w-4 text-amber-500 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
|
</div>
|
|
<span className="text-xs font-bold uppercase tracking-wider text-primary animate-pulse">Running Neural Simulation...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<ResponsiveContainer key={`${daysAhead}-${timeRange}`} width="100%" height="100%">
|
|
<AreaChart
|
|
data={chartData}
|
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
>
|
|
<defs>
|
|
<linearGradient id="colorBaseline" x1="0" y1="0" x2="0" y2="1">
|
|
<stop
|
|
offset="5%"
|
|
stopColor="#8884d8"
|
|
stopOpacity={0.3}
|
|
/>
|
|
<stop
|
|
offset="95%"
|
|
stopColor="#8884d8"
|
|
stopOpacity={0}
|
|
/>
|
|
</linearGradient>
|
|
<linearGradient id="colorSimulated" x1="0" y1="0" x2="0" y2="1">
|
|
<stop
|
|
offset="5%"
|
|
stopColor="#10b981"
|
|
stopOpacity={0.5}
|
|
/>
|
|
<stop
|
|
offset="95%"
|
|
stopColor="#10b981"
|
|
stopOpacity={0}
|
|
/>
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border) / 0.4)" />
|
|
<XAxis
|
|
dataKey="formattedDate"
|
|
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
dy={15}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(value) => `£${value}`}
|
|
/>
|
|
<RechartsTooltip
|
|
cursor={{ fill: "transparent", stroke: "hsl(var(--primary) / 0.05)", strokeWidth: 40 }}
|
|
content={({ active, payload }) => {
|
|
if (active && payload?.length) {
|
|
const data = payload[0].payload;
|
|
return (
|
|
<div className="bg-[#050505] p-5 rounded-2xl shadow-2xl border border-white/10 backdrop-blur-2xl ring-1 ring-white/5">
|
|
<p className="font-bold text-[10px] uppercase tracking-wide text-muted-foreground mb-4 border-b border-white/5 pb-3 px-1">{data.formattedDate}</p>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between gap-10">
|
|
<span className="text-[10px] font-bold text-muted-foreground/60 uppercase tracking-wide">Baseline:</span>
|
|
<span className="text-sm font-bold text-[#8884d8] tabular-nums">{formatGBP(data.baseline)}</span>
|
|
</div>
|
|
{committedSimulationFactor !== 0 && (
|
|
<div className="flex items-center justify-between gap-10">
|
|
<span className="text-[10px] font-bold text-muted-foreground/60 uppercase tracking-wide">Simulated:</span>
|
|
<div className="flex flex-col items-end">
|
|
<span className="text-sm font-bold text-emerald-400 tabular-nums">{formatGBP(data.simulated)}</span>
|
|
<span className={`text-[10px] font-bold mt-0.5 ${data.simulated > data.baseline ? 'text-emerald-500' : 'text-rose-500'}`}>
|
|
{data.simulated > data.baseline ? '▴' : '▾'} {Math.abs(((data.simulated / data.baseline - 1) * 100)).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between gap-10 pt-3 border-t border-white/5">
|
|
<span className="text-[10px] font-bold text-muted-foreground/60 uppercase tracking-widest">Est. Orders:</span>
|
|
<span className="text-sm font-bold tabular-nums">
|
|
{Math.round(data.orders)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
/>
|
|
{/* Always show baseline */}
|
|
<Area
|
|
type="monotone"
|
|
dataKey="baseline"
|
|
stroke="#8884d8"
|
|
fillOpacity={1}
|
|
fill="url(#colorBaseline)"
|
|
strokeWidth={3}
|
|
dot={false}
|
|
activeDot={{ r: 4, strokeWidth: 0, fill: "#8884d8" }}
|
|
/>
|
|
{/* Show simulated line when simulation is active */}
|
|
{committedSimulationFactor !== 0 && (
|
|
<Area
|
|
type="monotone"
|
|
dataKey="simulated"
|
|
stroke="#10b981"
|
|
fillOpacity={1}
|
|
fill="url(#colorSimulated)"
|
|
strokeWidth={3}
|
|
strokeDasharray="5 5"
|
|
dot={false}
|
|
activeDot={{ r: 6, strokeWidth: 3, stroke: "#fff", fill: "#10b981" }}
|
|
/>
|
|
)}
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</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 ? (
|
|
<div className="space-y-1">
|
|
<div className="font-medium">
|
|
{prediction.prediction.daysUntilOutOfStock} days
|
|
</div>
|
|
{prediction.prediction.optimisticDays !== null &&
|
|
prediction.prediction.pessimisticDays && (
|
|
<div className="text-xs text-muted-foreground">
|
|
{prediction.prediction.optimisticDays} -{" "}
|
|
{prediction.prediction.pessimisticDays} days
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
"N/A"
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{prediction.prediction.estimatedDate ? (
|
|
<div className="space-y-1">
|
|
<div>
|
|
{format(
|
|
new Date(
|
|
prediction.prediction.estimatedDate,
|
|
),
|
|
"MMM d, yyyy",
|
|
)}
|
|
</div>
|
|
{prediction.prediction.optimisticDate &&
|
|
prediction.prediction.pessimisticDate && (
|
|
<div className="text-xs text-muted-foreground">
|
|
{format(
|
|
new Date(
|
|
prediction.prediction.optimisticDate,
|
|
),
|
|
"MMM d",
|
|
)}{" "}
|
|
-{" "}
|
|
{format(
|
|
new Date(
|
|
prediction.prediction.pessimisticDate,
|
|
),
|
|
"MMM d",
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
"N/A"
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
className={getConfidenceColor(
|
|
prediction.prediction.confidence,
|
|
)}
|
|
>
|
|
{getConfidenceLabel(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 >
|
|
);
|
|
}
|