diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index 77cf7c4..ac1e01d 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -989,6 +989,79 @@ router.get('/best-sellers', async (req, res) => { } }); +// GET /dashboard/year-revenue-estimate +// Returns YTD actual revenue + rest-of-year forecast revenue for a full-year estimate +router.get('/year-revenue-estimate', async (req, res) => { + const now = new Date(); + const yearStart = `${now.getFullYear()}-01-01`; + const todayISO = now.toISOString().split('T')[0]; + const yearEndISO = `${now.getFullYear()}-12-31`; + + try { + // YTD actual revenue from orders + const { rows: [ytd] } = await executeQuery(` + SELECT COALESCE(SUM(price * quantity), 0) AS revenue + FROM orders + WHERE date >= $1 AND date <= $2 AND canceled = false + `, [yearStart, todayISO]); + + // Forecast horizon + const { rows: [horizonRow] } = await executeQuery( + `SELECT MAX(forecast_date) AS max_date FROM product_forecasts` + ); + const forecastHorizonISO = horizonRow?.max_date + ? (horizonRow.max_date instanceof Date + ? horizonRow.max_date.toISOString().split('T')[0] + : horizonRow.max_date) + : todayISO; + + const clampedEnd = yearEndISO <= forecastHorizonISO ? yearEndISO : forecastHorizonISO; + + // Forecast revenue from tomorrow to clamped end + const { rows: [forecast] } = await executeQuery(` + SELECT COALESCE(SUM(pf.forecast_revenue), 0) AS revenue + FROM product_forecasts pf + JOIN product_metrics pm ON pm.pid = pf.pid + WHERE pm.is_visible = true + AND pf.forecast_date > $1 AND pf.forecast_date <= $2 + `, [todayISO, clampedEnd]); + + let eoyForecastRevenue = parseFloat(forecast.revenue) || 0; + + // If forecast doesn't cover full year, extrapolate remaining days + if (yearEndISO > forecastHorizonISO) { + const { rows: [tailRow] } = await executeQuery(` + SELECT AVG(daily_rev) AS avg_daily FROM ( + SELECT forecast_date, SUM(pf.forecast_revenue) AS daily_rev + FROM product_forecasts pf + JOIN product_metrics pm ON pm.pid = pf.pid + WHERE pm.is_visible = true + AND pf.forecast_date > ($1::date - INTERVAL '7 days') + AND pf.forecast_date <= $1 + GROUP BY forecast_date + ) sub + `, [forecastHorizonISO]); + + const baselineDaily = parseFloat(tailRow?.avg_daily) || 0; + const horizonDate = new Date(forecastHorizonISO + 'T00:00:00'); + const yearEnd = new Date(yearEndISO + 'T00:00:00'); + const extraDays = Math.round((yearEnd - horizonDate) / (1000 * 60 * 60 * 24)); + eoyForecastRevenue += baselineDaily * extraDays; + } + + const ytdRevenue = parseFloat(ytd.revenue) || 0; + + res.json({ + ytdRevenue, + eoyForecastRevenue, + yearTotal: ytdRevenue + eoyForecastRevenue, + }); + } catch (err) { + console.error('Error fetching year revenue estimate:', err); + res.status(500).json({ error: 'Failed to fetch year revenue estimate' }); + } +}); + // GET /dashboard/sales/metrics // Returns sales metrics for specified period router.get('/sales/metrics', async (req, res) => { diff --git a/inventory/src/components/dashboard/DateTime.jsx b/inventory/src/components/dashboard/DateTime.jsx index 939aa6f..e39dbe3 100644 --- a/inventory/src/components/dashboard/DateTime.jsx +++ b/inventory/src/components/dashboard/DateTime.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { useQuery } from "@tanstack/react-query"; import { Card, CardContent } from '@/components/ui/card'; import { Calendar as CalendarComponent } from '@/components/ui/calendaredit'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -34,8 +35,44 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => { const [prevTime, setPrevTime] = useState(getTimeComponents(new Date())); const [isTimeChanging, setIsTimeChanging] = useState(false); const [mounted, setMounted] = useState(false); - const [weather, setWeather] = useState(null); - const [forecast, setForecast] = useState(null); + const { data: weatherData } = useQuery({ + queryKey: ["weather-current-forecast"], + queryFn: async () => { + const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY; + const [weatherResponse, forecastResponse] = await Promise.all([ + fetch( + `https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial` + ), + fetch( + `https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial` + ) + ]); + + const weather = await weatherResponse.json(); + const forecastData = await forecastResponse.json(); + + const dailyForecasts = forecastData.list.reduce((acc, item) => { + const date = new Date(item.dt * 1000).toLocaleDateString(); + if (!acc[date]) { + acc[date] = { + ...item, + precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0, + pop: item.pop * 100, + }; + } + return acc; + }, {}); + + return { + weather, + forecast: Object.values(dailyForecasts).slice(0, 5), + }; + }, + refetchInterval: 300000, + }); + + const weather = weatherData?.weather ?? null; + const forecast = weatherData?.forecast ?? null; useEffect(() => { setTimeout(() => setMounted(true), 150); @@ -43,60 +80,18 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => { const timer = setInterval(() => { const newDate = new Date(); const newTime = getTimeComponents(newDate); - + if (newTime.minutes !== prevTime.minutes) { setIsTimeChanging(true); setTimeout(() => setIsTimeChanging(false), 200); } - + setPrevTime(newTime); setDatetime(newDate); }, 1000); return () => clearInterval(timer); }, [prevTime]); - useEffect(() => { - const fetchWeatherData = async () => { - try { - const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY; - const [weatherResponse, forecastResponse] = await Promise.all([ - fetch( - `https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial` - ), - fetch( - `https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial` - ) - ]); - - const weatherData = await weatherResponse.json(); - const forecastData = await forecastResponse.json(); - - setWeather(weatherData); - - // Process forecast data to get daily forecasts with precipitation - const dailyForecasts = forecastData.list.reduce((acc, item) => { - const date = new Date(item.dt * 1000).toLocaleDateString(); - if (!acc[date]) { - acc[date] = { - ...item, - precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0, - pop: item.pop * 100 // Probability of precipitation as percentage - }; - } - return acc; - }, {}); - - setForecast(Object.values(dailyForecasts).slice(0, 5)); - } catch (error) { - console.error("Error fetching weather:", error); - } - }; - - fetchWeatherData(); - const weatherTimer = setInterval(fetchWeatherData, 300000); - return () => clearInterval(weatherTimer); - }, []); - function getTimeComponents(date) { let hours = date.getHours(); const minutes = date.getMinutes(); @@ -242,7 +237,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => { const WeatherDetails = () => (
- {timestamp.toLocaleDateString([], { - weekday: "short", - month: "short", - day: "numeric" - })} -
+ {date && ( ++ {date.toLocaleDateString([], { + weekday: "short", + month: "short", + day: "numeric", + })} +
+ )}