diff --git a/inventory/src/components/dashboard/AnalyticsDashboard.jsx b/inventory/src/components/dashboard/AnalyticsDashboard.jsx index bd875c4..35f3545 100644 --- a/inventory/src/components/dashboard/AnalyticsDashboard.jsx +++ b/inventory/src/components/dashboard/AnalyticsDashboard.jsx @@ -16,7 +16,6 @@ import { YAxis, CartesianGrid, Tooltip, - Legend, ResponsiveContainer, } from "recharts"; import { TrendingUp } from "lucide-react"; @@ -31,12 +30,15 @@ import { DashboardChartTooltip, ChartSkeleton, DashboardEmptyState, + MetricPill, + LegendChip, + PILL_TRIGGER_CLASS, } from "@/components/dashboard/shared"; // Note: Using ChartSkeleton from @/components/dashboard/shared const SkeletonStats = () => ( -
+
{[...Array(4)].map((_, i) => ( ))} @@ -48,19 +50,19 @@ const SkeletonStats = () => ( // Add color constants const METRIC_COLORS = { activeUsers: { - color: "#8b5cf6", + color: "#6b5fc7", className: "text-purple-600 dark:text-purple-400", }, newUsers: { - color: "#10b981", + color: "#0f8f77", className: "text-emerald-600 dark:text-emerald-400", }, pageViews: { - color: "#f59e0b", + color: "#a87a24", className: "text-amber-600 dark:text-amber-400", }, conversions: { - color: "#3b82f6", + color: "#e06a4e", className: "text-blue-600 dark:text-blue-400", }, }; @@ -163,7 +165,7 @@ export const AnalyticsDashboard = () => { // Time selector for DashboardSectionHeader const timeSelector = ( - - - - - {GROUP_BY_CHOICES.map((option) => ( - - {option.label} - - ))} - - -
+
+ {SERIES_DEFINITIONS.map((series) => ( + + ))} + +
)} {loading ? ( @@ -1308,7 +1306,7 @@ const FinancialOverview = () => { /> ) : ( <> -
+
{!hasActiveMetrics ? ( { /> ) : ( - + @@ -1354,7 +1352,6 @@ const FinancialOverview = () => { /> )} } /> - SERIES_LABELS[value as ChartSeriesKey] ?? value} /> {/* Stacked areas showing revenue breakdown */} {metrics.cogs ? ( +
{cards.map((card) => ( +
{Array.from({ length: 4 }).map((_, index) => ( ))} diff --git a/inventory/src/components/dashboard/Header.jsx b/inventory/src/components/dashboard/Header.jsx index bbe2d1d..5f78a80 100644 --- a/inventory/src/components/dashboard/Header.jsx +++ b/inventory/src/components/dashboard/Header.jsx @@ -1,376 +1,38 @@ -import React, { useState, useEffect } from "react"; -import { Card, CardContent } from "@/components/ui/card"; -import { - Calendar, - Clock, - Sun, - Cloud, - CloudRain, - CloudDrizzle, - CloudSnow, - CloudLightning, - CloudFog, - CloudSun, - CircleAlert, - Tornado, - Haze, - Moon, - Monitor, - Wind, - Droplets, - ThermometerSun, - ThermometerSnowflake, - Sunrise, - Sunset, - AlertTriangle, - Umbrella, -} from "lucide-react"; -import { useScroll } from "@/contexts/DashboardScrollContext"; -import { useTheme } from "@/components/dashboard/theme/ThemeProvider"; -import { cn } from "@/lib/utils"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { CARD_STYLES } from "@/lib/dashboard/designTokens"; +import React from "react"; -const CraftsIcon = () => ( - -); +const formatDate = (date) => + date.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + }); -const Header = () => { - const [currentTime, setCurrentTime] = useState(new Date()); - const [weather, setWeather] = useState(null); - const [forecast, setForecast] = useState(null); - const { isStuck } = useScroll(); - const { theme, systemTheme, toggleTheme, setTheme } = useTheme(); - - useEffect(() => { - const timer = setInterval(() => { - setCurrentTime(new Date()); - }, 1000); - return () => clearInterval(timer); - }, []); - - 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 - 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; - }, {}); - - setForecast(Object.values(dailyForecasts).slice(0, 5)); - } catch (error) { - console.error("Error fetching weather:", error); - } - }; - - fetchWeatherData(); - const weatherTimer = setInterval(fetchWeatherData, 300000); - return () => clearInterval(weatherTimer); - }, []); - - const getWeatherIcon = (weatherCode, currentTime, small = false) => { - if (!weatherCode) return ; - - const code = parseInt(weatherCode, 10); - const iconProps = small ? "w-6 h-6" : "w-7 h-7"; - - switch (true) { - case code >= 200 && code < 300: - return ; - case code >= 300 && code < 500: - return ; - case code >= 500 && code < 600: - return ; - case code >= 600 && code < 700: - return ; - case code >= 700 && code < 721: - return ; - case code === 721: - return ; - case code >= 722 && code < 781: - return ; - case code === 781: - return ; - case code === 800: - return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? ( - - ) : ( - - ); - case code >= 800 && code < 803: - return ; - case code >= 803: - return ; - default: - return ; - } - }; - - const formatTime = (timestamp) => { - if (!timestamp) return '--:--'; - const date = new Date(timestamp * 1000); - return date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true - }); - }; - - const WeatherDetails = () => ( -
-
- -
- -
- High - {Math.round(weather.main.temp_max)}°F -
-
-
- - -
- -
- Low - {Math.round(weather.main.temp_min)}°F -
-
-
- - -
- -
- Humidity - {weather.main.humidity}% -
-
-
- - -
- -
- Wind - {Math.round(weather.wind.speed)} mph -
-
-
- - -
- -
- Sunrise - {formatTime(weather.sys?.sunrise)} -
-
-
- - -
- -
- Sunset - {formatTime(weather.sys?.sunset)} -
-
-
-
- - {forecast && ( -
-
- {forecast.map((day, index) => ( - -
- - {new Date(day.dt * 1000).toLocaleDateString('en-US', { weekday: 'short' })} - - {getWeatherIcon(day.weather[0].id, new Date(day.dt * 1000), true)} -
- - {Math.round(day.main.temp_max)}° - - - {Math.round(day.main.temp_min)}° - -
-
- {day.rain?.['3h'] > 0 && ( -
- - {day.rain['3h'].toFixed(2)}" -
- )} - {day.snow?.['3h'] > 0 && ( -
- - {day.snow['3h'].toFixed(2)}" -
- )} - {!day.rain?.['3h'] && !day.snow?.['3h'] && ( -
- - 0" -
- )} -
-
-
- ))} -
-
- )} -
- ); - - const formatDate = (date) => - date.toLocaleDateString("en-US", { - weekday: "short", - year: "numeric", - month: "short", - day: "numeric", - }); - - const formatTimeDisplay = (date) => { - const hours = date.getHours(); - const minutes = String(date.getMinutes()).padStart(2, "0"); - const seconds = String(date.getSeconds()).padStart(2, "0"); - const period = hours >= 12 ? "PM" : "AM"; - const displayHours = hours % 12 || 12; - return `${displayHours}:${minutes}:${seconds} ${period}`; - }; +const greeting = (date) => { + const hour = date.getHours(); + if (hour < 12) return "Good morning"; + if (hour < 17) return "Good afternoon"; + return "Good evening"; +}; +const Header = ({ right = null }) => { + const now = new Date(); return ( - - -
-
-
-
- -
-
-
-

- ACOT Dashboard -

-
-
-
- {weather?.main && ( - <> -
- - -
- {getWeatherIcon(weather.weather[0]?.id, currentTime)} -
-

- {Math.round(weather.main.temp)}° F -

-
- {weather.alerts && ( - - )} -
-
- - {weather.alerts && ( - - - - {weather.alerts[0].event} - - - )} - - -
-
- - )} -
-
- -
-

- {formatDate(currentTime)} -

-
-
-
-
- -
-

- {formatTimeDisplay(currentTime)} -

-
-
-
+
+
+
+

+ {greeting(now)} +

+

+ Here's how the shop is doing +

- - +
+ {right} + {formatDate(now)} +
+
+
); }; diff --git a/inventory/src/components/dashboard/KlaviyoCampaigns.jsx b/inventory/src/components/dashboard/KlaviyoCampaigns.jsx index 170eeab..509330d 100644 --- a/inventory/src/components/dashboard/KlaviyoCampaigns.jsx +++ b/inventory/src/components/dashboard/KlaviyoCampaigns.jsx @@ -49,18 +49,18 @@ const MetricCellContent = ({ if (isSMS && hideForSMS) { return (
-
N/A
-
-
+
N/A
+
-
); } return (
-
+
{isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)}
-
+
{count?.toLocaleString() || 0} {count === 1 ? "recipient" : "recipients"} {showConversionRate && totalRecipients > 0 && @@ -245,21 +245,6 @@ const KlaviyoCampaigns = ({ className }) => { /> ), }, - { - key: "click_to_open_rate", - header: "CTR", - align: "center", - sortable: true, - render: (_, campaign) => ( - - ), - }, { key: "conversion_value", header: "Orders", @@ -284,11 +269,11 @@ const KlaviyoCampaigns = ({ className }) => { } timeSelector={
} /> - + @@ -301,60 +286,58 @@ const KlaviyoCampaigns = ({ className }) => { )} - - - +
+ {/* When all channels are on, "active" reads as the default state so + all three highlight; clicking one isolates it, clicking again + resets to all. Only the isolated channel highlights then. */} + {(() => { + const allOn = selectedChannels.email && selectedChannels.sms && selectedChannels.blog; + const toggle = (channel) => + setSelectedChannels((prev) => { + const onlyThis = prev[channel] && Object.values(prev).filter(Boolean).length === 1; + if (onlyThis) return { email: true, sms: true, blog: true }; + return { + email: channel === "email", + sms: channel === "sms", + blog: channel === "blog", + }; + }); + return [ + { key: "email", label: "Email" }, + { key: "sms", label: "SMS" }, + { key: "blog", label: "Blog" }, + ].map(({ key, label }) => { + const active = !allOn && selectedChannels[key]; + return ( + + ); + }); + })()}
} timeSelector={ } /> - + -
+
{formattedValue}
{(label || sublabel) && ( -
+
{label || sublabel}
)} @@ -385,28 +388,6 @@ const MetaCampaigns = () => { /> ), }, - { - key: "impressions", - header: "Impressions", - align: "center", - sortable: true, - render: (_, campaign) => ( - - ), - }, - { - key: "cpm", - header: "CPM", - align: "center", - sortable: true, - render: (_, campaign) => ( - - ), - }, { key: "ctr", header: "CTR", @@ -447,15 +428,6 @@ const MetaCampaigns = () => { /> ), }, - { - key: "engagements", - header: "Engagements", - align: "center", - sortable: true, - render: (_, campaign) => ( - - ), - }, ]; if (loading) { @@ -464,17 +436,15 @@ const MetaCampaigns = () => { } /> - -
+ +
{[...Array(12)].map((_, i) => ( ))}
- - @@ -498,10 +468,10 @@ const MetaCampaigns = () => { - + @@ -514,96 +484,24 @@ const MetaCampaigns = () => { } /> - -
- - - - - - - - - - - - + + {/* Summary metrics as a quiet strip — same deemphasized treatment as + the secondary stats on the Overview section */} +
+ + + + + + + + + + + +
- - { - const [activeSections, setActiveSections] = useState([]); + const [activeSection, setActiveSection] = useState(null); const { isStuck, scrollContainerRef, scrollToSection } = useScroll(); const { user } = useContext(AuthContext); + const navRef = useRef(null); const buttonRefs = useRef({}); - const scrollContainerRef2 = useRef(null); - const [shouldAutoScroll, setShouldAutoScroll] = useState(true); - const lastScrollLeft = useRef(0); - const lastScrollTop = useRef(0); - // Define all possible sections with their permission requirements - const allSections = [ - { id: "stats", label: "Statistics", permission: "dashboard:stats" }, - { id: "realtime", label: "Realtime", permission: "dashboard:realtime" }, - { id: "financial", label: "Financial", permission: "dashboard:financial" }, - { id: "payroll-metrics", label: "Payroll", permission: "dashboard:payroll" }, - { id: "operations-metrics", label: "Operations", permission: "dashboard:operations" }, - { id: "feed", label: "Event Feed", permission: "dashboard:feed" }, - { id: "sales", label: "Sales Chart", permission: "dashboard:sales" }, - { id: "products", label: "Top Products", permission: "dashboard:products" }, - { id: "campaigns", label: "Campaigns", permission: "dashboard:campaigns" }, - { id: "analytics", label: "Analytics", permission: "dashboard:analytics" }, - { id: "user-behavior", label: "User Behavior", permission: "dashboard:user_behavior" }, - { id: "meta-campaigns", label: "Meta Ads", permission: "dashboard:meta_campaigns" }, - { id: "typeform", label: "Customer Surveys", permission: "dashboard:typeform" }, - ]; - - // Filter sections based on user permissions - const baseSections = useMemo(() => { + const sections = useMemo(() => { if (!user) return []; - - // Admins see all sections - if (user.is_admin) return allSections; - - // Filter sections based on user permissions - return allSections.filter(section => - user.permissions && user.permissions.includes(section.permission) + if (user.is_admin) return ALL_SECTIONS; + return ALL_SECTIONS.filter((section) => + user.permissions?.includes(section.permission) ); }, [user]); - const sortSections = (sections) => { - const isMediumScreen = window.matchMedia( - "(min-width: 768px) and (max-width: 1023px)" - ).matches; + useEffect(() => { + const container = scrollContainerRef.current; + if (!container || !sections.length) return undefined; - return [...sections].sort((a, b) => { - const aOrder = a.order - ? isMediumScreen - ? a.order.md - : a.order.default - : 0; - const bOrder = b.order - ? isMediumScreen - ? b.order.md - : b.order.default - : 0; + const updateActiveSection = () => { + const containerTop = container.getBoundingClientRect().top; + let current = sections[0]?.id ?? null; - if (aOrder && bOrder) { - return aOrder - bOrder; - } - return 0; + sections.forEach(({ id }) => { + const element = document.getElementById(id); + if (element && element.getBoundingClientRect().top - containerTop <= 140) { + current = id; + } + }); + + setActiveSection(current); + }; + + container.addEventListener("scroll", updateActiveSection, { passive: true }); + updateActiveSection(); + return () => container.removeEventListener("scroll", updateActiveSection); + }, [scrollContainerRef, sections]); + + useEffect(() => { + const activeButton = activeSection && buttonRefs.current[activeSection]; + activeButton?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", }); - }; - - const sections = sortSections(baseSections); + }, [activeSection]); const scrollToTop = () => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - top: 0, - behavior: "smooth", - }); - } else { - window.scrollTo({ - top: 0, - behavior: "smooth", - }); - } + scrollContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }; - const handleSectionClick = (sectionId, responsiveIds) => { - scrollToSection(sectionId); - }; - - // Track horizontal scroll position changes - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - const handleButtonBarScroll = () => { - if (Math.abs(container.scrollLeft - lastScrollLeft.current) > 5) { - setShouldAutoScroll(false); - } - lastScrollLeft.current = container.scrollLeft; - }; - - container.addEventListener("scroll", handleButtonBarScroll); - return () => container.removeEventListener("scroll", handleButtonBarScroll); - }, []); - - // Handle page scroll and active sections - useEffect(() => { - const handlePageScroll = (e) => { - const scrollTop = e?.target?.scrollTop || window.pageYOffset || document.documentElement.scrollTop; - - if (Math.abs(scrollTop - lastScrollTop.current) > 5) { - setShouldAutoScroll(true); - lastScrollTop.current = scrollTop; - } else { - return; - } - - const activeIds = []; - const viewportHeight = window.innerHeight; - const threshold = viewportHeight * 0.5; - const container = scrollContainerRef.current; - - sections.forEach((section) => { - if (section.responsiveIds) { - const visibleId = section.responsiveIds.find((id) => { - const element = document.getElementById(id); - if (!element) return false; - const style = window.getComputedStyle(element); - if (style.display === "none") return false; - - if (container) { - // For container-based scrolling - const rect = element.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - const relativeTop = rect.top - containerRect.top; - const relativeBottom = rect.bottom - containerRect.top; - return ( - relativeTop < containerRect.height - threshold && - relativeBottom > threshold - ); - } else { - // For window-based scrolling - const rect = element.getBoundingClientRect(); - return ( - rect.top < viewportHeight - threshold && rect.bottom > threshold - ); - } - }); - - if (visibleId) { - activeIds.push(section.id); - } - } else { - const element = document.getElementById(section.id); - if (element) { - if (container) { - // For container-based scrolling - const rect = element.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - const relativeTop = rect.top - containerRect.top; - const relativeBottom = rect.bottom - containerRect.top; - if ( - relativeTop < containerRect.height - threshold && - relativeBottom > threshold - ) { - activeIds.push(section.id); - } - } else { - // For window-based scrolling - const rect = element.getBoundingClientRect(); - if ( - rect.top < viewportHeight - threshold && - rect.bottom > threshold - ) { - activeIds.push(section.id); - } - } - } - } - }); - - setActiveSections(activeIds); - - if (shouldAutoScroll && activeIds.length > 0) { - const firstActiveButton = buttonRefs.current[activeIds[0]]; - if (firstActiveButton && scrollContainerRef2.current) { - scrollContainerRef2.current.scrollTo({ - left: - firstActiveButton.offsetLeft - - scrollContainerRef2.current.offsetWidth / 2 + - firstActiveButton.offsetWidth / 2, - behavior: "auto", - }); - } - } - }; - - // Attach to container or window - const container = scrollContainerRef.current; - if (container) { - container.addEventListener("scroll", handlePageScroll); - handlePageScroll({ target: container }); - } else { - window.addEventListener("scroll", handlePageScroll); - handlePageScroll(); - } - - return () => { - if (container) { - container.removeEventListener("scroll", handlePageScroll); - } else { - window.removeEventListener("scroll", handlePageScroll); - } - }; - }, [sections, shouldAutoScroll, scrollContainerRef]); - return (
- - -
-
+ {sections.map(({ id, label }) => ( + - ))} -
-
-
- -
-
-
- + {label} + + ))} +
+ + {isStuck && ( + + )} +
); }; diff --git a/inventory/src/components/dashboard/OperationsMetrics.tsx b/inventory/src/components/dashboard/OperationsMetrics.tsx index 2636faa..c3fa756 100644 --- a/inventory/src/components/dashboard/OperationsMetrics.tsx +++ b/inventory/src/components/dashboard/OperationsMetrics.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { acotService } from "@/services/dashboard/acotService"; import { Card, CardContent } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; import { Select, SelectContent, @@ -9,7 +8,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; import { Table, TableBody, @@ -22,7 +20,6 @@ import { Area, CartesianGrid, ComposedChart, - Legend, Line, ResponsiveContainer, Tooltip, @@ -45,6 +42,8 @@ import { DashboardErrorState, TOOLTIP_STYLES, METRIC_COLORS, + MetricPill, + PILL_TRIGGER_CLASS, } from "@/components/dashboard/shared"; import { Tooltip as TooltipUI, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { toDateOnly } from "@/utils/businessTime"; @@ -598,7 +597,7 @@ const OperationsMetrics = () => { actions={headerActions} /> - + {!error && ( loading ? ( @@ -608,43 +607,35 @@ const OperationsMetrics = () => { )} {!error && ( -
-
- {SERIES_DEFINITIONS.map((series) => ( - - ))} -
- - - - -
-
Group:
- -
+
+ {SERIES_DEFINITIONS.map((series) => ( + toggleMetric(series.key)} + > + {series.label} + + ))} + +
)} {loading ? ( -
+
@@ -661,14 +652,14 @@ const OperationsMetrics = () => { description="Try selecting a different time range" /> ) : ( -
+
-
+
{!hasActiveMetrics ? ( { /> ) : ( - + @@ -706,7 +697,6 @@ const OperationsMetrics = () => { /> )} } /> - SERIES_LABELS[value as ChartSeriesKey] ?? value} /> {metrics.ordersPicked && ( +
{cards.map((card) => ( +
{Array.from({ length: 4 }).map((_, index) => ( ))} @@ -868,8 +858,8 @@ const OperationsTooltip = ({ active, payload, label }: TooltipProps -
+
+
@@ -954,26 +944,23 @@ function OperationsLeaderboard({ if (leaderboard.length === 0) { return ( -
+

No employee data

); } return ( -
-
-

Top Performers

-
+
- + - - Name - Picked - Shipped - + + Name + Picked + Shipped + Hours @@ -983,7 +970,7 @@ function OperationsLeaderboard({ - Speed + Speed diff --git a/inventory/src/components/dashboard/PayrollMetrics.tsx b/inventory/src/components/dashboard/PayrollMetrics.tsx index 8f8b891..b0b2aaf 100644 --- a/inventory/src/components/dashboard/PayrollMetrics.tsx +++ b/inventory/src/components/dashboard/PayrollMetrics.tsx @@ -20,7 +20,6 @@ import { import { Bar, CartesianGrid, - Legend, Line, ComposedChart, ResponsiveContainer, @@ -40,6 +39,8 @@ import { DashboardErrorState, TOOLTIP_STYLES, METRIC_COLORS, + LegendChip, + PILL_TRIGGER_CLASS, } from "@/components/dashboard/shared"; type ComparisonValue = { @@ -454,7 +455,7 @@ const PayrollMetrics = () => { }} disabled={loading} > - + @@ -470,7 +471,7 @@ const PayrollMetrics = () => {
- + - Name + Name {hasWeekData ? ( <> - Wk 1 - Wk 2 + Wk 1 + Wk 2 ) : ( - Regular + Regular )} - Total - OT + Total + OT {periodCount > 1 && ( - Avg/Per + Avg/Per )} diff --git a/inventory/src/components/dashboard/PeriodSelectionPopover.tsx b/inventory/src/components/dashboard/PeriodSelectionPopover.tsx index 20167fb..7159041 100644 --- a/inventory/src/components/dashboard/PeriodSelectionPopover.tsx +++ b/inventory/src/components/dashboard/PeriodSelectionPopover.tsx @@ -98,61 +98,70 @@ const PeriodSelectionPopover = ({ return ( - - +
-
Select Time Period
+
+ Time period +
@@ -161,26 +170,26 @@ const PeriodSelectionPopover = ({
-
Or enter a custom period:
+
Or enter a custom period:
handleInputChange(event.target.value)} onKeyDown={handleKeyDown} - className="h-8 text-sm" + className="h-8 rounded-full border-[#e7e5e1] px-3.5 text-sm shadow-none" /> {inputValue && (
{preview.label ? (
- + {preview.label}
) : ( -
+
Not recognized
)} diff --git a/inventory/src/components/dashboard/ProductGrid.jsx b/inventory/src/components/dashboard/ProductGrid.jsx index 1ecce7c..dc87a08 100644 --- a/inventory/src/components/dashboard/ProductGrid.jsx +++ b/inventory/src/components/dashboard/ProductGrid.jsx @@ -1,376 +1,211 @@ import React, { useState, useEffect } from "react"; -import axios from "axios"; import { acotService } from "@/services/dashboard/acotService"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Loader2, ArrowUpDown, AlertCircle, Package, Settings2, Search, X } from "lucide-react"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Package, Search, X } from "lucide-react"; import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { CARD_STYLES, TYPOGRAPHY } from "@/lib/dashboard/designTokens"; +import { CARD_STYLES } from "@/lib/dashboard/designTokens"; import { DashboardEmptyState, DashboardErrorState } from "@/components/dashboard/shared"; +/** + * Top Sellers — ranked product list (Sorbet Studio). + * Rank badges cycle the pastel tile fills; revenue is the bold right column. + */ + +const RANK_FILLS = ["#fbe7de", "#ddf0ea", "#faf0d7", "#eae6f6"]; + +const SORTS = [ + { key: "totalQuantity", label: "Sold" }, + { key: "totalRevenue", label: "Revenue" }, +]; + const ProductGrid = ({ timeRange = "today", onTimeRangeChange, - title = "Top Products", - description + title = "Top sellers", }) => { const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedTimeRange, setSelectedTimeRange] = useState(timeRange); - const [sorting, setSorting] = useState({ - column: "totalQuantity", - direction: "desc", - }); + const [sortKey, setSortKey] = useState("totalQuantity"); const [searchQuery, setSearchQuery] = useState(""); const [isSearchVisible, setIsSearchVisible] = useState(false); useEffect(() => { - fetchProducts(); + let cancelled = false; + (async () => { + try { + setLoading(true); + setError(null); + const response = await acotService.getProducts({ timeRange: selectedTimeRange }); + if (!cancelled) setProducts(response.stats.products.list || []); + } catch (err) { + console.error("Error fetching products:", err); + if (!cancelled) setError(err.message); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; }, [selectedTimeRange]); - const fetchProducts = async () => { - try { - setLoading(true); - setError(null); - - const response = await acotService.getProducts({ timeRange: selectedTimeRange }); - setProducts(response.stats.products.list || []); - } catch (error) { - console.error("Error fetching products:", error); - setError(error.message); - } finally { - setLoading(false); - } - }; - const handleTimeRangeChange = (value) => { setSelectedTimeRange(value); - if (onTimeRangeChange) { - onTimeRangeChange(value); - } + if (onTimeRangeChange) onTimeRangeChange(value); }; - const handleSort = (column) => { - setSorting((prev) => ({ - column, - direction: - prev.column === column && prev.direction === "desc" ? "asc" : "desc", - })); - }; - - const sortedProducts = [...products].sort((a, b) => { - const direction = sorting.direction === "desc" ? -1 : 1; - const aValue = a[sorting.column]; - const bValue = b[sorting.column]; - - if (typeof aValue === "number") { - return (aValue - bValue) * direction; - } - return String(aValue).localeCompare(String(bValue)) * direction; - }); - - const filteredProducts = sortedProducts.filter(product => + const ranked = [...products].sort((a, b) => (b[sortKey] || 0) - (a[sortKey] || 0)); + const filtered = ranked.filter((product) => product.name.toLowerCase().includes(searchQuery.toLowerCase()) ); - const SkeletonProduct = () => ( -
- - - - - - - ); - - const LoadingState = () => ( -
-
-
- - -
- - -
-
- - - - - -
- - - - - - - - - - {[...Array(20)].map((_, i) => ( - - ))} - -
- - - - - - - - -
-
-
- ); - - if (loading) { - return ( - - -
-
-
- - - - {description && ( - - - - )} -
-
- - -
-
-
-
- - -
- -
-
-
- ); - } - return ( - -
-
-
- {title} - {description && ( - {description} - )} -
-
- {!error && ( - - )} - + {label} + + ))}
+ {!error && ( + + )} +
- {isSearchVisible && !error && ( -
- - setSearchQuery(e.target.value)} - className="pl-9 pr-9 h-9 w-full" - autoFocus - /> - {searchQuery && ( - - )} -
- )}
+ {isSearchVisible && !error && ( +
+ + setSearchQuery(e.target.value)} + className="h-8 w-full rounded-full border-[#e7e5e1] pl-9 pr-9 text-xs shadow-none" + autoFocus + /> + {searchQuery && ( + + )} +
+ )}
- -
- {error ? ( - - ) : !products?.length ? ( - - ) : ( -
-
- - - - - - - - - - - {filteredProducts.map((product) => ( - - - - - - - - ))} - -
- - - - - - - - -
- {product.ImgThumb && ( - (e.target.style.display = "none")} - /> - )} - -
- - - - - {product.name} - - - -

{product.name}

-
-
-
-
-
- {product.totalQuantity} - - ${product.totalRevenue.toFixed(2)} - - {product.orderCount} -
+ + {loading ? ( +
+ {[...Array(8)].map((_, i) => ( +
+
+
+
+
+
+
+
-
- )} -
+ ))} +
+ ) : error ? ( + + ) : !filtered.length ? ( + + ) : ( + + )} ); diff --git a/inventory/src/components/dashboard/RealtimeAnalytics.jsx b/inventory/src/components/dashboard/RealtimeAnalytics.jsx index fe019aa..b67f356 100644 --- a/inventory/src/components/dashboard/RealtimeAnalytics.jsx +++ b/inventory/src/components/dashboard/RealtimeAnalytics.jsx @@ -8,23 +8,20 @@ import { YAxis, Tooltip, ResponsiveContainer, + PieChart, + Pie, + Cell, } from "recharts"; -import { - Tooltip as UITooltip, - TooltipContent, - TooltipTrigger, - TooltipProvider, -} from "@/components/ui/tooltip"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { format } from "date-fns"; +import { Radio, Users } from "lucide-react"; // Import shared components and tokens import { DashboardChartTooltip, DashboardSectionHeader, - DashboardStatCard, DashboardTable, - StatCardSkeleton, ChartSkeleton, TableSkeleton, DashboardErrorState, @@ -36,15 +33,15 @@ import { // Realtime-specific colors using the standardized palette const REALTIME_COLORS = { activeUsers: { - color: METRIC_COLORS.aov, // Purple - className: "text-chart-aov", - }, - pages: { - color: METRIC_COLORS.revenue, // Emerald + color: METRIC_COLORS.revenue, // Studio coral — realtime is a headline metric className: "text-chart-revenue", }, + pages: { + color: METRIC_COLORS.orders, // Studio violet + className: "text-chart-orders", + }, sources: { - color: METRIC_COLORS.comparison, // Amber + color: METRIC_COLORS.comparison, // Studio teal className: "text-chart-comparison", }, }; @@ -52,10 +49,6 @@ const REALTIME_COLORS = { // Export for backwards compatibility export { REALTIME_COLORS as METRIC_COLORS }; -export const SkeletonSummaryCard = () => ( - -); - export const SkeletonBarChart = () => ( ); @@ -192,6 +185,87 @@ export const QuotaInfo = ({ tokenQuota }) => { ); }; +// Traffic-sources donut — mirrors the Device Usage tab in UserBehavior: +// ring with a center total and a labeled legend column (no on-slice labels). +const SOURCE_COLORS = ["#e06a4e", "#6b5fc7", "#0f8f77", "#a87a24", "#3f7fae", "#c94f76"]; + +const SourcesDonut = ({ sources, loading }) => { + if (loading) { + return ( +
+
+
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+ ); + } + + const top = [...(sources || [])] + .sort((a, b) => b.activeUsers - a.activeUsers) + .slice(0, 6); + const total = top.reduce((sum, s) => sum + s.activeUsers, 0); + + if (!total) { + return ( +
+ No active sources +
+ ); + } + + return ( +
+
+ + + + {top.map((entry, index) => ( + + ))} + + + +
+ {total} + active +
+
+
+ {top.map((entry, index) => ( +
+ + + {entry.source} + + {entry.activeUsers} + + {Math.round((entry.activeUsers / total) * 100)}% + +
+ ))} +
+
+ ); +}; + // Custom tooltip for the realtime chart const RealtimeTooltip = ({ active, payload }) => { if (active && payload && payload.length) { @@ -342,43 +416,29 @@ export const RealtimeAnalytics = () => { { key: "path", header: "Page", + // GA4's realtime API only exposes unifiedScreenName (page TITLE), not + // pagePath — so these can't be linked, unlike the 30-day Top Pages table. render: (value) => {value}, }, { key: "activeUsers", header: "Active Users", align: "right", + width: "w-[110px] min-w-[110px] whitespace-nowrap", render: (value) => {value}, }, ]; - // Column definitions for sources table - const sourcesColumns = [ - { - key: "source", - header: "Source", - render: (value) => {value}, - }, - { - key: "activeUsers", - header: "Active Users", - align: "right", - render: (value) => {value}, - }, - ]; if (loading && !basicData && !detailedData) { return ( - + - -
- - -
+ +
-
+
{[...Array(3)].map((_, i) => (
@@ -395,57 +455,55 @@ export const RealtimeAnalytics = () => { - - -
- Last updated:{" "} - {basicData.lastUpdated && format(new Date(basicData.lastUpdated), "h:mm a")} -
-
- - - -
- + + Updated{" "} + {basicData.lastUpdated && format(new Date(basicData.lastUpdated), "h:mm a")} + } /> - + {error && ( )} -
- - +
+
+
+ + +
+
+

Active now

+

+ {basicData.last5MinUsers} +

+
+
+
+
+ + 30 minutes +
+

{basicData.last30MinUsers}

+
- + Activity Current Pages Traffic Sources -
+
{ -
+
`${page.path}-${index}`} - maxHeight="sm" compact />
-
- `${source.source}-${index}`} - maxHeight="sm" - compact - /> +
+
@@ -501,4 +551,60 @@ export const RealtimeAnalytics = () => { ); }; +/** + * RealtimeChip — compact live-visitors pill for the page header. The full + * realtime panel (chart + pages + sources tabs) lives behind a popover, per + * the Studio design: realtime is ambient chrome, not a standing section. + */ +export const RealtimeChip = () => { + const [count, setCount] = useState(null); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const response = await apiFetch("/api/dashboard-analytics/realtime/basic", { + credentials: "include", + }); + if (!response.ok) return; + const result = await response.json(); + if (!cancelled) setCount(processBasicData(result.data).last5MinUsers); + } catch { + // ambient chip — stay quiet on errors + } + }; + load(); + const t = setInterval(load, 30000); + return () => { + cancelled = true; + clearInterval(t); + }; + }, []); + + return ( + + + + + e.preventDefault()} + > + + + + ); +}; + export default RealtimeAnalytics; diff --git a/inventory/src/components/dashboard/SalesChart.jsx b/inventory/src/components/dashboard/SalesChart.jsx index df932a1..e349721 100644 --- a/inventory/src/components/dashboard/SalesChart.jsx +++ b/inventory/src/components/dashboard/SalesChart.jsx @@ -32,7 +32,7 @@ import { } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; import { Separator } from "@/components/ui/separator"; -import { CARD_STYLES } from "@/lib/dashboard/designTokens"; +import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens"; import { DashboardSectionHeader, DashboardStatCard, @@ -41,18 +41,19 @@ import { ChartSkeleton, DashboardEmptyState, DashboardErrorState, - METRIC_COLORS, + MetricPill, } from "@/components/dashboard/shared"; -// Chart color mapping using semantic tokens +// Chart color mapping — Sorbet Studio series hues (prev-* stay dashed in the +// chart; the lighter prev tones are only legal alongside that dash) const CHART_COLORS = { - revenue: METRIC_COLORS.revenue, - prevRevenue: METRIC_COLORS.comparison, - orders: METRIC_COLORS.orders, - prevOrders: METRIC_COLORS.secondary, - aov: METRIC_COLORS.aov, - prevAov: METRIC_COLORS.comparison, - movingAverage: METRIC_COLORS.comparison, + revenue: STUDIO_COLORS.coral, + prevRevenue: STUDIO_COLORS.teal, + orders: STUDIO_COLORS.violet, + prevOrders: "#9c92de", + aov: STUDIO_COLORS.amber, + prevAov: "#c9a45b", + movingAverage: "#a8a29a", }; // Move formatCurrency to top and export it @@ -224,105 +225,56 @@ const calculateSummaryStats = (data = []) => { }; }; -// Add memoized SummaryStats component +// Compact inline totals — replaces the old four-card summary row. The hero +// tiles above the fold already carry the headline numbers; this line just +// anchors the chart to its period totals. const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading = false }) => { const { totalRevenue = 0, totalOrders = 0, avgOrderValue = 0, - bestDay = null, prevRevenue = 0, prevOrders = 0, - prevAvgOrderValue = 0, - periodProgress = 100 + periodProgress = 100, } = stats; - // Calculate projected values when period is incomplete - const currentRevenue = periodProgress < 100 ? (projection?.projectedRevenue || totalRevenue) : totalRevenue; - const revenueTrend = currentRevenue >= prevRevenue ? "up" : "down"; - const revenueDiff = Math.abs(currentRevenue - prevRevenue); - const revenuePercentage = (revenueDiff / prevRevenue) * 100; + const currentRevenue = + periodProgress < 100 ? projection?.projectedRevenue || totalRevenue : totalRevenue; + const currentOrders = + periodProgress < 100 ? projection?.projectedOrders || totalOrders : totalOrders; + const showTrends = !(projectionLoading && periodProgress < 100); + const revPct = prevRevenue > 0 ? ((currentRevenue - prevRevenue) / prevRevenue) * 100 : null; + const ordPct = prevOrders > 0 ? ((currentOrders - prevOrders) / prevOrders) * 100 : null; - // Calculate order trends - const currentOrders = periodProgress < 100 ? (projection?.projectedOrders || totalOrders) : totalOrders; - const ordersTrend = currentOrders >= prevOrders ? "up" : "down"; - const ordersDiff = Math.abs(currentOrders - prevOrders); - const ordersPercentage = (ordersDiff / prevOrders) * 100; - - // Calculate AOV trends - const currentAOV = currentOrders ? currentRevenue / currentOrders : avgOrderValue; - const aovTrend = currentAOV >= prevAvgOrderValue ? "up" : "down"; - const aovDiff = Math.abs(currentAOV - prevAvgOrderValue); - const aovPercentage = (aovDiff / prevAvgOrderValue) * 100; - - // Convert trend direction to numeric value for DashboardStatCard - const getNumericTrend = (trendDir, percentage) => { - if (projectionLoading && periodProgress < 100) return undefined; - if (!isFinite(percentage)) return undefined; - return trendDir === "up" ? percentage : -percentage; + const Delta = ({ pct }) => { + if (!showTrends || pct == null || !isFinite(pct) || Math.abs(pct) < 0.1) return null; + return ( + 0 ? "text-[#237a4d]" : "text-[#b3503f]"}> + {" "} + {pct > 0 ? "↑" : "↓"} + {Math.abs(pct).toFixed(1)}% + + ); }; return ( -
- - - - - - - +
+ Revenue{" "} + + {formatCurrency(totalRevenue, false)} + + + · + Orders{" "} + + {totalOrders.toLocaleString()} + + + · + AOV{" "} + + {formatCurrency(avgOrderValue)} +
); }); @@ -332,14 +284,10 @@ SummaryStats.displayName = "SummaryStats"; // Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared const SkeletonStats = () => ( -
- {[...Array(4)].map((_, i) => ( - - ))} -
+
); -const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => { +const SalesChart = ({ timeRange = "last30days", title = "Sales" }) => { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -466,11 +414,15 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => { const headerActions = !error ? ( - - + Daily Details @@ -478,8 +430,9 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
- - - -
- - - - - + Revenue + + setMetrics((prev) => ({ ...prev, orders: !prev.orders }))} + > + Orders + + setMetrics((prev) => ({ ...prev, avgOrderValue: !prev.avgOrderValue }))} + > + AOV + + setMetrics((prev) => ({ ...prev, movingAverage: !prev.movingAverage }))} + > + 7-day avg + + + setMetrics((prev) => ({ ...prev, showPrevious: !prev.showPrevious }))} + > + vs previous +
)} {loading ? ( @@ -774,46 +698,50 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => { /> ) : ( <> -
+
formatCurrency(value, false)} - className="text-xs text-muted-foreground" - tick={{ fill: "currentColor" }} + tick={{ fill: "#a09b92", fontSize: 11 }} + tickLine={false} + axisLine={false} /> value.toLocaleString()} - className="text-xs text-muted-foreground" - tick={{ fill: "currentColor" }} + tick={{ fill: "#a09b92", fontSize: 11 }} + tickLine={false} + axisLine={false} /> } /> - {metrics.revenue && ( diff --git a/inventory/src/components/dashboard/StatCards.jsx b/inventory/src/components/dashboard/StatCards.jsx index 9e45b51..0ed0935 100644 --- a/inventory/src/components/dashboard/StatCards.jsx +++ b/inventory/src/components/dashboard/StatCards.jsx @@ -28,8 +28,6 @@ import { // Import Tooltip from recharts with alias to avoid naming conflict import { Tooltip as RechartsTooltip } from "recharts"; import { - DollarSign, - ShoppingCart, Package, Clock, Map, @@ -39,11 +37,11 @@ import { TrendingUp, AlertCircle, RefreshCcw, - CircleDollarSign, MapPin, } from "lucide-react"; import { DateTime } from "luxon"; -import { CARD_STYLES } from "@/lib/dashboard/designTokens"; +import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens"; +import StudioStatTile from "@/components/dashboard/studio/StudioStatTile"; import { DashboardStatCard, DashboardStatCardSkeleton, @@ -52,6 +50,7 @@ import { DashboardEmptyState, DashboardErrorState, TOOLTIP_STYLES, + QuietStat, } from "@/components/dashboard/shared"; import { Table, @@ -90,7 +89,7 @@ const TimeSeriesChart = ({ data, dataKey, name, - color = "hsl(var(--primary))", + color = "#e06a4e", type = "line", valueFormatter = (value) => value, height = 400, @@ -110,7 +109,7 @@ const TimeSeriesChart = ({ data={data} margin={{ top: 10, right: 20, left: -20, bottom: 5 }} > - + DateTime.fromISO(value).toFormat("LLL d")} @@ -161,7 +160,7 @@ const TimeSeriesChart = ({ const DetailDialog = ({ open, onOpenChange, title, children }) => ( - + {title} @@ -196,7 +195,7 @@ const RevenueDetails = ({ data }) => { data={chartData} dataKey="revenue" name="Revenue" - color="hsl(142.1 76.2% 36.3%)" + color="#e06a4e" valueFormatter={(value) => formatCurrency(value)} height={400} /> @@ -217,7 +216,7 @@ const OrdersDetails = ({ data }) => { data={data} dataKey="orders" name="Orders" - color="hsl(221.2 83.2% 53.3%)" + color="#6b5fc7" /> {data[0]?.hourlyOrders && (
@@ -230,7 +229,7 @@ const OrdersDetails = ({ data }) => { dataKey="orders" name="Orders" type="bar" - color="hsl(221.2 83.2% 53.3%)" + color="#6b5fc7" />
)} @@ -252,7 +251,7 @@ const AverageOrderDetails = ({ data }) => { data={data} dataKey="averageOrderValue" name="Average Order Value" - color="hsl(262.1 83.3% 57.8%)" + color="#a87a24" valueFormatter={(value) => formatCurrency(value)} /> ); @@ -293,7 +292,7 @@ const CancellationsDetails = ({ data }) => { data={timeSeriesData} dataKey="total" name="Cancellation Amount" - color="hsl(0 84.2% 60.2%)" + color="#b3503f" valueFormatter={(value) => formatCurrency(value)} />
@@ -305,7 +304,7 @@ const CancellationsDetails = ({ data }) => { dataKey="count" name="Cancellation Count" type="bar" - color="hsl(0 84.2% 60.2%)" + color="#b3503f" />
@@ -629,7 +628,7 @@ const PeakHourDetails = ({ data }) => { data={hourlyData} margin={{ top: 10, right: 30, left: 20, bottom: 5 }} > - + { @@ -833,7 +832,7 @@ const OrderRangeDetails = ({ data }) => { data={timeSeriesData} dataKey="average" name="Average Order Value" - color="hsl(142.1 76.2% 36.3%)" + color="#e06a4e" valueFormatter={(value) => formatCurrency(value)} />
@@ -881,7 +880,7 @@ const OrderRangeDetails = ({ data }) => { data={formattedDistributionData} margin={{ top: 10, right: 20, left: -20, bottom: 40 }} > - + clearInterval(interval); }, [timeRange]); + // 30-day daily series for the hero-tile sparklines. Context stays 30d + // regardless of the selected range — a one-point "today" series has no shape. + const [sparkRows, setSparkRows] = useState(null); + useEffect(() => { + let cancelled = false; + (async () => { + try { + const cached = getCacheData("last30days", "hero_sparklines"); + if (cached) { + setSparkRows(cached); + return; + } + const response = await acotService.getStatsDetails({ + timeRange: "last30days", + metric: "revenue", + daily: true, + }); + if (cancelled) return; + const rows = Array.isArray(response.stats) ? response.stats : []; + setCacheData("last30days", "hero_sparklines", rows); + setSparkRows(rows); + } catch { + // sparklines are optional garnish — tiles render fine without them + } + })(); + return () => { + cancelled = true; + }; + }, [getCacheData, setCacheData]); + + // Financials for the Gross Profit hero tile (selected range) and its + // 30-day sparkline. Both cached; the tile degrades to an em dash on failure. + const [profitData, setProfitData] = useState(null); + useEffect(() => { + if (timeRange === "custom") { + setProfitData(null); + return undefined; + } + let cancelled = false; + (async () => { + try { + const cached = getCacheData(timeRange, "hero_financials"); + if (cached) { + setProfitData(cached); + return; + } + const response = await acotService.getFinancials({ timeRange }); + if (cancelled) return; + setCacheData(timeRange, "hero_financials", response); + setProfitData(response); + } catch { + // tile shows an em dash without financials + } + })(); + return () => { + cancelled = true; + }; + }, [timeRange, getCacheData, setCacheData]); + + const [profitSparkRows, setProfitSparkRows] = useState(null); + useEffect(() => { + let cancelled = false; + (async () => { + try { + const cached = getCacheData("last30days", "hero_profit_spark"); + if (cached) { + setProfitSparkRows(cached); + return; + } + const response = await acotService.getFinancials({ timeRange: "last30days" }); + if (cancelled) return; + const rows = Array.isArray(response?.trend) ? response.trend : []; + setCacheData("last30days", "hero_profit_spark", rows); + setProfitSparkRows(rows); + } catch { + // sparkline is optional garnish + } + })(); + return () => { + cancelled = true; + }; + }, [getCacheData, setCacheData]); + // Fetch detail data when a metric is selected (if not already cached) useEffect(() => { if (!selectedMetric) return; @@ -1414,69 +1497,42 @@ const StatCards = ({ if (loading && !stats) { return ( - - -
-
-
- - {title} - - {description && ( - - {description} - - )} -
-
- - -
+
+
+ + +
+
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+ {[...Array(6)].map((_, i) => ( +
+
+
-
- - -
- {[...Array(12)].map((_, i) => ( - - ))} -
-
- + ))} +
+
); } if (error) { return ( - - -
-
-
- - {title} - - {description && ( - - {description} - - )} -
-
- {lastUpdate && !loading && ( - - Last updated: {lastUpdate.toFormat("hh:mm a")} - - )} - -
-
-
-
- +
+
+ +
+
- - +
+
); } @@ -1487,56 +1543,43 @@ const StatCards = ({ const aovTrend = calculateAOVTrend(); const isSingleDay = ["today", "yesterday"].includes(timeRange); + const profitTotals = profitData?.totals; + const prevProfitTotal = profitData?.previousTotals?.profit; + // Mid-period, compare a PROJECTED end-of-period profit against the previous + // period (same as revenue/orders) — raw partial-day profit vs a full prior + // day would read as a huge drop until midnight. Projection assumes the + // current margin holds: profit × (projectedRevenue / revenue so far). + const profitTrendPct = (() => { + if (!profitTotals || !(prevProfitTotal > 0)) return null; + if (stats?.periodProgress < 100) { + const projRevForTrend = projection?.projectedRevenue || stats.projectedRevenue; + if (!(stats.revenue > 0) || !projRevForTrend) return null; + const projectedProfit = profitTotals.profit * (projRevForTrend / stats.revenue); + return ((projectedProfit - prevProfitTotal) / prevProfitTotal) * 100; + } + return ( + profitData?.comparison?.profit?.percentage ?? + ((profitTotals.profit - prevProfitTotal) / prevProfitTotal) * 100 + ); + })(); + const sparkProfit = profitSparkRows?.map((d) => Number(d.profit) || 0); + + const sparkRevenue = sparkRows?.map((d) => Number(d.revenue) || 0); + const sparkOrders = sparkRows?.map((d) => Number(d.orders) || 0); + const sparkAov = sparkRows?.map((d) => + Number(d.orders) > 0 ? (Number(d.revenue) || 0) / Number(d.orders) : 0 + ); + return ( - - -
-
-
- - {title} - {lastUpdate && !loading && ( -
- Last updated {lastUpdate.toFormat("h:mm a")} - {projection?.confidence > 0 && !projectionLoading && ( - - - - - ( - - {Math.round(projection.confidence * 100)}% - - ) - - - -

Confidence level of revenue projection based on historical data patterns

-
-
-
- )} -
- )} -
- -
- -
- -
-
-
-
- -
- + {/* Slim control row — no card chrome, the tiles speak for themselves */} +
+ +
+
+ {/* Hero tiles — Sorbet Studio pastel identities with 30d sparklines */} +
+ setSelectedMetric("revenue")} loading={loading || !stats} /> - setSelectedMetric("orders")} loading={loading || !stats} /> - setSelectedMetric("average_order")} loading={loading || !stats} /> - setSelectedMetric("brands_categories")} + +
- setSelectedMetric("shipping")} - loading={loading || !stats} - /> - - + 0 ? ((stats?.orderTypes?.preOrders?.count / stats?.orderCount) * 100).toFixed(1) : "0" - } - valueSuffix="%" - subtitle={`${stats?.orderTypes?.preOrders?.count || 0} orders`} - icon={Clock} - iconColor="yellow" + }%`} + sub={`${stats?.orderTypes?.preOrders?.count || 0} orders`} onClick={() => setSelectedMetric("pre_orders")} loading={loading || !stats} /> - - 0 ? ((stats?.orderTypes?.localPickup?.count / stats?.orderCount) * 100).toFixed(1) : "0" - } - valueSuffix="%" - subtitle={`${stats?.orderTypes?.localPickup?.count || 0} orders`} - icon={Map} - iconColor="cyan" + }%`} + sub={`${stats?.orderTypes?.localPickup?.count || 0} orders`} onClick={() => setSelectedMetric("local_pickup")} loading={loading || !stats} /> - - 0 ? ((stats?.orderTypes?.heldItems?.count / stats?.orderCount) * 100).toFixed(1) : "0" - } - valueSuffix="%" - subtitle={`${stats?.orderTypes?.heldItems?.count || 0} orders`} - icon={AlertCircle} - iconColor="red" + }%`} + sub={`${stats?.orderTypes?.heldItems?.count || 0} orders`} onClick={() => setSelectedMetric("on_hold")} loading={loading || !stats} /> - - {isSingleDay ? ( - setSelectedMetric("peak_hour")} - loading={loading || !stats} - /> - ) : ( - - )} - - setSelectedMetric("shipping")} + loading={loading || !stats} + /> + setSelectedMetric("refunds")} loading={loading || !stats} /> - - setSelectedMetric("cancellations")} loading={loading || !stats} /> - - setSelectedMetric("order_range")} - loading={loading || !stats} - />
{getDetailComponent()} - - +
+
); }; diff --git a/inventory/src/components/dashboard/TypeformDashboard.jsx b/inventory/src/components/dashboard/TypeformDashboard.jsx index d6cd7ab..a2ae5ab 100644 --- a/inventory/src/components/dashboard/TypeformDashboard.jsx +++ b/inventory/src/components/dashboard/TypeformDashboard.jsx @@ -42,13 +42,13 @@ const FORM_NAMES = { }; const ResponseFeed = ({ responses, title, renderSummary }) => ( - - - + + + -
+
{responses.items.map((response) => ( -
+
{renderSummary(response)}
))} @@ -73,12 +73,12 @@ const ProductRelevanceFeed = ({ responses }) => ( {response.hidden?.email ? ( {response.hidden?.name || "Anonymous"} ) : ( - + {response.hidden?.name || "Anonymous"} )} @@ -87,14 +87,14 @@ const ProductRelevanceFeed = ({ responses }) => (
{textAnswer && ( -
"{textAnswer}"
+
"{textAnswer}"
)}
); @@ -122,12 +122,12 @@ const WinbackFeed = ({ responses }) => ( {response.hidden?.email ? ( {response.hidden?.name || "Anonymous"} ) : ( - + {response.hidden?.name || "Anonymous"} )} @@ -150,7 +150,7 @@ const WinbackFeed = ({ responses }) => (
{feedbackAnswer?.text && ( -
+
{feedbackAnswer.text}
)} @@ -368,56 +368,51 @@ const TypeformDashboard = () => { `Newest response: ${format(date, "MMM d, h:mm a")}`} - className="pb-0" + size="large" /> - + {loading ? ( -
+
) : ( <> -
- - -
- +
+ + +
+ How likely are you to place another order with us? {metrics.winback.averageRating} - + /5 avg
- -
+ +
- + { @@ -430,9 +425,16 @@ const TypeformDashboard = () => { textAnchor="middle" interval={0} height={50} - className="text-muted-foreground text-xs md:text-sm" + tick={{ fill: "#a09b92", fontSize: 11 }} + tickLine={false} + axisLine={{ stroke: "#eceae5" }} + /> + - { /> } /> - + {likelihoodCounts.map((_, index) => ( ))} @@ -464,98 +466,40 @@ const TypeformDashboard = () => {
- - -
- - Were the suggested products in this email relevant to you? - -
- - {metrics.productRelevance.yesPercentage}% Relevant - -
-
+ + + + Were the suggested products in this email relevant to you? + - -
- - - - - { - if (payload && payload.length) { - const yesCount = payload[0].payload.yes; - const noCount = payload[0].payload.no; - const total = yesCount + noCount; - const yesPercent = Math.round((yesCount / total) * 100); - const noPercent = Math.round((noCount / total) * 100); - return ( -
-
-
-
- - Yes -
- {yesCount} ({yesPercent}%) -
-
-
- - No -
- {noCount} ({noPercent}%) -
-
-
- ); - } - return null; - }} - /> - + {/* CSS proportion bar — guarantees matching rounded ends on + both segments (recharts stacked-bar radius rounds unreliably) */} + {(() => { + const yes = metrics.productRelevance.yesCount; + const no = metrics.productRelevance.noCount; + const total = yes + no || 1; + const yesPct = Math.round((yes / total) * 100); + return ( +
+
- - {metrics.productRelevance.yesPercentage}% - - - - - -
-
+ {yesPct >= 12 && `${yesPct}%`} +
+
+ {100 - yesPct >= 12 && `${100 - yesPct}%`} +
+
+ ); + })()} +
Yes: {metrics.productRelevance.yesCount}
No: {metrics.productRelevance.noCount}
@@ -563,11 +507,11 @@ const TypeformDashboard = () => {
-
+
- - - + + + { @@ -136,7 +137,19 @@ export const UserBehaviorDashboard = () => { { key: "path", header: "Page Path", - render: (value) => {value}, + render: (value) => + typeof value === "string" && value.startsWith("/") ? ( + + {value} + + ) : ( + {value} + ), }, { key: "pageViews", @@ -163,28 +176,28 @@ export const UserBehaviorDashboard = () => { { key: "source", header: "Source", - width: "w-[35%] min-w-[120px]", - render: (value) => {value}, + width: "w-[40%]", + render: (value) => {value}, }, { key: "sessions", header: "Sessions", align: "right", - width: "w-[20%] min-w-[80px]", + width: "w-[19%] whitespace-nowrap", render: (value) => {value.toLocaleString()}, }, { key: "conversions", header: "Conv.", align: "right", - width: "w-[20%] min-w-[80px]", + width: "w-[17%] whitespace-nowrap", render: (value) => {value.toLocaleString()}, }, { key: "conversionRate", header: "Conv. Rate", align: "right", - width: "w-[25%] min-w-[80px]", + width: "w-[24%] whitespace-nowrap", render: (_, row) => ( {((row.conversions / row.sessions) * 100).toFixed(1)}% @@ -195,30 +208,30 @@ export const UserBehaviorDashboard = () => { if (loading) { return ( - + } /> - + - + Top Pages Traffic Sources Device Usage - + - + - + @@ -228,9 +241,9 @@ export const UserBehaviorDashboard = () => { } const COLORS = { - desktop: "#8b5cf6", // Purple - mobile: "#10b981", // Green - tablet: "#f59e0b", // Yellow + desktop: "#6b5fc7", // Purple + mobile: "#0f8f77", // Green + tablet: "#a87a24", // Yellow }; const deviceData = data?.data?.pageData?.deviceData || []; @@ -270,13 +283,13 @@ export const UserBehaviorDashboard = () => { }; return ( - + - + {timeRange === "7" && "Last 7 days"} {timeRange === "14" && "Last 14 days"} @@ -293,67 +306,108 @@ export const UserBehaviorDashboard = () => { } /> - - - + + {/* Fixed-height tab body so the card doesn't grow/shrink per tab; the + tables scroll internally instead. On xl the card fills the Analytics + panel beside it (absolute-fill in Dashboard.tsx). */} + + Top Pages Traffic Sources Device Usage - + `${page.path}-${index}`} - maxHeight="xl" compact /> - + `${source.source}-${index}`} - maxHeight="xl" compact /> - -
- - - - `${name} ${(percent * 100).toFixed(1)}%` - } - > - {deviceData.map((entry, index) => ( - + {/* Studio donut: slice gaps + center total + labeled rows (no on-slice labels) */} +
+
+ + + + {deviceData.map((entry, index) => ( + + ))} + + payload?.[0]?.payload?.device || ""} + itemRenderer={deviceTooltipRenderer} + /> + } + /> + + +
+ + {totalViews >= 1000 + ? `${(totalViews / 1000).toFixed(1)}K` + : totalViews.toLocaleString()} + + views +
+
+
+ {deviceData.map((entry) => { + const pct = + totalViews > 0 + ? ((entry.pageViews / totalViews) * 100).toFixed(1) + : "0.0"; + return ( +
+ - ))} - - payload?.[0]?.payload?.device || ""} - itemRenderer={deviceTooltipRenderer} - /> - } - /> - - + + {entry.device} + + + {entry.pageViews.toLocaleString()} + + + {pct}% + +
+ ); + })} +
diff --git a/inventory/src/components/dashboard/nightboard/NightboardSmall.tsx b/inventory/src/components/dashboard/nightboard/NightboardSmall.tsx new file mode 100644 index 0000000..7d3e6ff --- /dev/null +++ b/inventory/src/components/dashboard/nightboard/NightboardSmall.tsx @@ -0,0 +1,861 @@ +/** + * NightboardSmall — the /small kiosk dashboard, Nightboard style. + * + * Designed for the office panel: 10.1" 1920×1200 at 1× CSS scale, read from + * across the room. Hierarchy comes from scale and spacing, not cards — sizes + * below are tuned to that fixed viewport, so no transform-scale hacks. + * Data hooks mirror the retired Mini* components one-to-one; only the + * presentation changed (weather/calendar dropped, clock lives in the top line). + */ +import React, { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + AreaChart, + Area, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { acotService } from "@/services/dashboard/acotService"; +import { apiClient } from "@/utils/apiClient"; +import { apiFetch } from "@/utils/api"; +import config from "@/config"; +import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases"; +// @ts-expect-error - JSX module without type declarations +import { processBasicData } from "@/components/dashboard/RealtimeAnalytics"; +// @ts-expect-error - JSX module without type declarations +import { EventDialog } from "@/components/dashboard/EventFeed.jsx"; +import LockButton from "@/components/dashboard/LockButton"; + +// ── palette (Nightboard) ───────────────────────────────────────────────────── +const NB = { + bg: "#0e1420", + txt: "#e6ebf4", + mut: "#8292a8", + faint: "#46536b", + line: "#1d2738", + amberText: "#ffb454", // large type only + amber: "#c9822a", // chart marks (CVD-validated on this ground) + up: "#58b98a", + dn: "#d4707c", + card: "#131b2c", +} as const; + +// ── formatters ─────────────────────────────────────────────────────────────── +const fmtMoney = (v: number | null | undefined) => + v == null || isNaN(v) + ? "—" + : new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format(v); + +const fmtCompact = (v: number | null | undefined) => { + if (v == null || isNaN(v)) return "—"; + if (Math.abs(v) >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`; + if (Math.abs(v) >= 1_000) return `$${(v / 1_000).toFixed(0)}K`; + return `$${Math.round(v)}`; +}; + +const fmtCount = (v: number | null | undefined) => { + if (v == null || isNaN(v)) return "—"; + if (v >= 10_000) return `${(v / 1_000).toFixed(1)}K`; + return v.toLocaleString(); +}; + +const trendPct = (cur: number, prev: number) => + prev > 0 ? ((cur - prev) / prev) * 100 : null; + +// ── tiny presentational atoms ──────────────────────────────────────────────── +const Lbl = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +const TrendArrow = ({ pct, invert = false }: { pct: number | null; invert?: boolean }) => { + if (pct == null || Math.abs(pct) < 0.1) return null; + const good = invert ? pct < 0 : pct > 0; + return ( + + {pct > 0 ? "▲" : "▼"} {Math.abs(pct).toFixed(1)}% + + ); +}; + +// ── metric ids for the event feed (same Klaviyo metrics as MiniEventFeed) ──── +const METRIC_IDS = { + PLACED_ORDER: "Y8cqcF", + SHIPPED_ORDER: "VExpdL", + ACCOUNT_CREATED: "TeeypV", + CANCELED_ORDER: "YjVMNg", + NEW_BLOG_POST: "YcxeDr", + PAYMENT_REFUNDED: "R7XUYh", +} as const; + +const EVENT_META: Record = { + [METRIC_IDS.PLACED_ORDER]: { label: "Order", dot: NB.up }, + [METRIC_IDS.SHIPPED_ORDER]: { label: "Shipped", dot: "#5580d0" }, + [METRIC_IDS.ACCOUNT_CREATED]: { label: "Account", dot: "#8f7ad6" }, + [METRIC_IDS.CANCELED_ORDER]: { label: "Canceled", dot: NB.dn }, + [METRIC_IDS.PAYMENT_REFUNDED]: { label: "Refund", dot: "#e0a15a" }, + [METRIC_IDS.NEW_BLOG_POST]: { label: "Blog", dot: NB.mut }, +}; + +const fmtEventMoney = (amount: string | number | undefined) => { + const num = typeof amount === "string" ? parseFloat(amount) : amount; + if (num == null || isNaN(num)) return ""; + return `${num < 0 ? "-" : ""}$${Math.abs(num).toFixed(2)}`; +}; + +const shipMethod = (method: string | undefined) => { + if (!method) return "Digital"; + if (method.includes("usps")) return "USPS"; + if (method.includes("fedex")) return "FedEx"; + if (method.includes("ups")) return "UPS"; + return "Standard"; +}; + +interface EventProps { + ShippingName?: string; + OrderId?: string | number; + TotalAmount?: string | number; + IsOnHold?: boolean; + StillOwes?: boolean; + LocalPickup?: boolean; + HasPreorder?: boolean; + ShipMethod?: string; + ShippedBy?: string; + FirstName?: string; + LastName?: string; + EmailAddress?: string; + CancelReason?: string; + FromOrder?: string | number; + PaymentAmount?: string | number; + PaymentName?: string; + title?: string; + description?: string; +} + +interface FeedEvent { + id: string; + metric_id: string; + datetime?: string; + attributes?: { datetime?: string; event_properties?: EventProps }; + event_properties: EventProps; +} + +interface PhaseSlice { + phase: string; + revenue: number; + percentage: number; +} + +// ── response shapes (acotService is untyped JS; these mirror what the API returns) ── +interface TodayStats { + revenue: number; + orderCount: number; + itemCount: number; + averageOrderValue: number; + averageItemsPerOrder: number; + periodProgress: number; + prevPeriodRevenue: number; + prevPeriodOrders: number; + prevPeriodAOV: number; + projectedRevenue?: number; +} + +interface Projection { + projectedRevenue?: number; + projectedOrders?: number; +} + +interface DailyRow { + revenue?: number; + orders?: number; + prevRevenue?: number; + prevOrders?: number; + periodProgress?: number; +} + +interface FinancialsResponse { + totals?: Record; + previousTotals?: Record; + comparison?: { margin?: { absolute?: number } }; +} + +// ── clock ──────────────────────────────────────────────────────────────────── +const Clock = () => { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const t = setInterval(() => setNow(new Date()), 10_000); + return () => clearInterval(t); + }, []); + return ( + + {now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })} + {" · "} + + {now.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })} + + + ); +}; + +// ── main component ─────────────────────────────────────────────────────────── +const NightboardSmall = () => { + // Today stats (same source as MiniStatCards) + const { data: stats } = useQuery({ + queryKey: ["nightboard-stats-today"], + queryFn: async () => { + const res = (await acotService.getStats({ timeRange: "today" })) as { stats: TodayStats }; + return res.stats; + }, + refetchInterval: 60_000, + }); + + const { data: projection } = useQuery({ + queryKey: ["nightboard-projection-today"], + queryFn: async () => + (await acotService.getProjection({ timeRange: "today" })) as Projection, + enabled: stats != null && stats.periodProgress < 100, + refetchInterval: 60_000, + }); + + const { data: realtime } = useQuery({ + queryKey: ["nightboard-realtime-users"], + queryFn: async () => { + const response = await apiFetch("/api/dashboard-analytics/realtime/basic", { + credentials: "include", + }); + if (!response.ok) throw new Error("Failed to fetch realtime"); + const result = await response.json(); + return processBasicData(result.data) as { last30MinUsers: number; last5MinUsers: number }; + }, + refetchInterval: 30_000, + }); + + // 30-day summary + avg/day (same source as MiniSalesChart / MiniBusinessMetrics) + const { data: sum30 } = useQuery({ + queryKey: ["nightboard-stats-30d"], + queryFn: async () => { + const response = (await acotService.getStatsDetails({ + timeRange: "last30days", + metric: "revenue", + daily: true, + })) as { stats?: DailyRow[] }; + const rows = Array.isArray(response.stats) ? response.stats : []; + const t = rows.reduce>( + (acc, day) => ({ + revenue: acc.revenue + (Number(day.revenue) || 0), + orders: acc.orders + (Number(day.orders) || 0), + prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0), + prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0), + periodProgress: day.periodProgress || 100, + }), + { revenue: 0, orders: 0, prevRevenue: 0, prevOrders: 0, periodProgress: 100 } + ); + const days = rows.length || 1; + return { + ...t, + avgPerDay: t.revenue / days, + prevAvgPerDay: t.prevRevenue / days, + }; + }, + refetchInterval: 300_000, + }); + + const { data: projection30 } = useQuery({ + queryKey: ["nightboard-projection-30d"], + queryFn: async () => + (await acotService.getProjection({ timeRange: "last30days" })) as Projection, + enabled: sum30 != null && sum30.periodProgress < 100, + refetchInterval: 300_000, + }); + + // 30-day chart + phase mix (same endpoint as MiniSalesChart) + const { data: chartData } = useQuery({ + queryKey: ["nightboard-sales-chart-30d"], + queryFn: async () => { + const now = new Date(); + const thirtyDaysAgo = new Date(now); + thirtyDaysAgo.setDate(now.getDate() - 30); + const params = new URLSearchParams({ + startDate: thirtyDaysAgo.toISOString(), + endDate: now.toISOString(), + }); + const response = await apiFetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`); + if (!response.ok) throw new Error("Failed to fetch sales metrics"); + return response.json(); + }, + refetchInterval: 300_000, + }); + + // Business band (same sources as MiniBusinessMetrics) + const { data: forecastData } = useQuery({ + queryKey: ["nightboard-forecast-30d"], + queryFn: async () => { + const response = await apiFetch(`${config.apiUrl}/dashboard/forecast/metrics`); + if (!response.ok) throw new Error("Failed to fetch forecast"); + return response.json(); + }, + refetchInterval: 600_000, + }); + + const { data: yearData } = useQuery({ + queryKey: ["nightboard-year-estimate"], + queryFn: async () => { + const response = await apiFetch(`${config.apiUrl}/dashboard/year-revenue-estimate`); + if (!response.ok) throw new Error("Failed to fetch year estimate"); + return response.json(); + }, + refetchInterval: 600_000, + }); + + const { data: financialData } = useQuery({ + queryKey: ["nightboard-financials-30d"], + queryFn: async () => + (await acotService.getFinancials({ timeRange: "last30days" })) as FinancialsResponse, + refetchInterval: 300_000, + }); + + const { data: fteData } = useQuery({ + queryKey: ["nightboard-fte-30d"], + queryFn: async () => + // @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type + (await acotService.getEmployeeMetrics({ timeRange: "last30days" })) as { + totals?: { fte?: number }; + previousTotals?: { fte?: number }; + }, + refetchInterval: 300_000, + }); + + // Ops / inventory band (same sources as MiniInventorySnapshot) + const { data: opsData } = useQuery({ + queryKey: ["nightboard-ops-today"], + queryFn: async () => + // @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type + (await acotService.getOperationsMetrics({ timeRange: "today" })) as { + totals?: { ordersShipped?: number; piecesPicked?: number }; + }, + refetchInterval: 120_000, + }); + + const { data: stockData } = useQuery({ + queryKey: ["nightboard-stock-metrics"], + queryFn: async () => { + const response = await apiFetch(`${config.apiUrl}/dashboard/stock/metrics`); + if (!response.ok) throw new Error("Failed to fetch stock metrics"); + return response.json(); + }, + refetchInterval: 300_000, + }); + + const { data: replenishData } = useQuery({ + queryKey: ["nightboard-replenish-metrics"], + queryFn: async () => { + const response = await apiFetch(`${config.apiUrl}/dashboard/replenishment/metrics`); + if (!response.ok) throw new Error("Failed to fetch replenishment"); + return response.json(); + }, + refetchInterval: 300_000, + }); + + const { data: overstockData } = useQuery({ + queryKey: ["nightboard-overstock-metrics"], + queryFn: async () => { + const response = await apiFetch(`${config.apiUrl}/dashboard/overstock/metrics`); + if (!response.ok) throw new Error("Failed to fetch overstock"); + return response.json(); + }, + refetchInterval: 300_000, + }); + + // Event feed (same source as MiniEventFeed) + const { data: events = [] } = useQuery({ + queryKey: ["nightboard-event-feed"], + queryFn: async () => { + const response = await apiClient.get("/api/klaviyo/events/feed", { + params: { + timeRange: "today", + metricIds: JSON.stringify(Object.values(METRIC_IDS)), + }, + }); + return (response.data.data || []).map((event: FeedEvent) => ({ + ...event, + datetime: event.attributes?.datetime || event.datetime, + event_properties: event.attributes?.event_properties || {}, + })); + }, + refetchInterval: 30_000, + }); + + const feedRef = useRef(null); + useEffect(() => { + if (feedRef.current && events.length > 0) { + const el = feedRef.current; + const t = setTimeout( + () => el.scrollTo({ left: el.scrollWidth, behavior: "instant" as ScrollBehavior }), + 150 + ); + return () => clearTimeout(t); + } + }, [events]); + + // ── derived values ───────────────────────────────────────────────────────── + const inProgress = stats != null && stats.periodProgress < 100; + const projRevenue = projection?.projectedRevenue ?? stats?.projectedRevenue ?? null; + const revTrend = + stats && stats.prevPeriodRevenue > 0 + ? trendPct( + inProgress ? projRevenue ?? stats.revenue : stats.revenue, + stats.prevPeriodRevenue + ) + : null; + const projOrders = + projection?.projectedOrders ?? + (stats && stats.periodProgress > 0 + ? Math.round(stats.orderCount / (stats.periodProgress / 100)) + : null); + + const rev30Current = + sum30 && sum30.periodProgress < 100 + ? projection30?.projectedRevenue || sum30.revenue + : sum30?.revenue; + const rev30Trend = sum30 ? trendPct(rev30Current ?? 0, sum30.prevRevenue) : null; + const orders30Current = + sum30 && sum30.periodProgress < 100 && sum30.periodProgress > 0 + ? Math.round(sum30.orders * (100 / sum30.periodProgress)) + : sum30?.orders; + const orders30Trend = sum30 ? trendPct(orders30Current ?? 0, sum30.prevOrders) : null; + + const dailyTotals: { date: string; total: number }[] = (chartData?.dailySalesByPhase || []).map( + (day: { date: string } & Record) => ({ + date: day.date, + total: PHASE_KEYS.reduce((sum, key) => sum + (Number(day[key]) || 0), 0), + }) + ); + const activePhases: PhaseSlice[] = (chartData?.phaseBreakdown || []) + .filter((p: PhaseSlice) => p.revenue > 0) + .sort((a: PhaseSlice, b: PhaseSlice) => b.revenue - a.revenue); + + // margin (same fallback math as MiniBusinessMetrics) + let margin: number | null = null; + let prevMargin: number | null = null; + if (financialData?.totals) { + const t = financialData.totals; + if (Number.isFinite(t.margin)) margin = t.margin; + else { + const income = (t.grossSales || 0) - (t.refunds || 0) - (t.discounts || 0) + (t.shippingFees || 0); + margin = income > 0 ? ((income - (t.cogs || 0)) / income) * 100 : 0; + } + const prev = financialData.previousTotals; + if (prev) { + if (Number.isFinite(prev.margin)) prevMargin = prev.margin; + else { + const pIncome = + (prev.grossSales || 0) - (prev.refunds || 0) - (prev.discounts || 0) + (prev.shippingFees || 0); + prevMargin = pIncome > 0 ? ((pIncome - (prev.cogs || 0)) / pIncome) * 100 : 0; + } + } + } + + const fte = fteData?.totals?.fte ?? null; + const prevFte = fteData?.previousTotals?.fte ?? null; + const ops = opsData?.totals; + + const band: { label: string; value: string; sub: React.ReactNode; group?: boolean }[] = [ + { + label: "Forecast 30d", + value: fmtCompact(forecastData?.forecastRevenue), + sub: yearData?.yearTotal != null ? `${fmtCompact(yearData.yearTotal)} for year` : "—", + }, + { + label: "Avg rev/day", + value: sum30 ? fmtMoney(sum30.avgPerDay) : "—", + sub: sum30 ? ( + <> + prev {fmtMoney(sum30.prevAvgPerDay)}{" "} + + + ) : ( + "—" + ), + }, + { + label: "Margin", + value: margin != null ? `${margin.toFixed(1)}%` : "—", + sub: + prevMargin != null && margin != null ? ( + <> + prev {prevMargin.toFixed(1)}%{" "} + = prevMargin ? NB.up : NB.dn }}> + {margin >= prevMargin ? "▲" : "▼"} {Math.abs(margin - prevMargin).toFixed(1)}pp + + + ) : ( + "—" + ), + }, + { + label: "Payroll FTE", + value: fte != null ? fte.toFixed(1) : "—", + sub: + prevFte != null && fte != null ? ( + <> + prev {prevFte.toFixed(1)} + + ) : ( + "—" + ), + }, + { + label: "Shipped today", + value: ops ? fmtCount(ops.ordersShipped) : "—", + sub: ops ? `${fmtCount(ops.piecesPicked)} pcs picked` : "—", + group: true, + }, + { + label: "Stock value", + value: fmtCompact(stockData?.totalStockCost), + sub: stockData ? `${fmtCount(stockData.productsInStock)} products` : "—", + }, + { + label: "Replenish", + value: replenishData ? `${fmtCount(replenishData.unitsToReplenish)} u` : "—", + sub: replenishData ? `${fmtCompact(replenishData.replenishmentCost)} cost` : "—", + }, + { + label: "Overstocked", + value: overstockData ? fmtCount(overstockData.overstockedProducts) : "—", + sub: overstockData ? `${fmtCompact(overstockData.totalExcessCost)} excess` : "—", + }, + ]; + + return ( +
+ {/* top line */} +
+ + A Cherry On Top + +
+ + + + +
+
+ + {/* hero metrics */} +
+
+ Revenue today +
+ {fmtMoney(stats?.revenue)} +
+
+ {inProgress && projRevenue != null && ( + <> + proj {fmtMoney(projRevenue)} + {" · "} + + )} + {revTrend != null && "vs last week"} +
+
+
+ Orders +
+ {stats?.orderCount?.toLocaleString() ?? "—"} +
+
+ {projOrders != null && inProgress && ( + <> + proj {projOrders} + {" · "} + + )} + {stats?.itemCount != null ? `${fmtCount(stats.itemCount)} items` : ""} +
+
+
+ Avg order +
+ {stats?.averageOrderValue != null ? `$${stats.averageOrderValue.toFixed(2)}` : "—"} +
+
+ {stats?.averageItemsPerOrder != null + ? `${stats.averageItemsPerOrder.toFixed(1)} items / order` + : ""} +
+
+
+ On site now +
+ + + + + {realtime?.last5MinUsers ?? "—"} +
+
+ {realtime != null ? `${realtime.last30MinUsers} last 30 min` : ""} +
+
+
+ + {/* 30-day chart */} +
+
+ Last 30 days + + Revenue {fmtCompact(sum30?.revenue)}{" "} + + {" · "} + Orders {fmtCount(sum30?.orders)}{" "} + + +
+
+ + + + + + + + + + new Date(v).toLocaleDateString([], { month: "numeric", day: "numeric" }) + } + tick={{ fill: NB.mut, fontSize: 14 }} + tickLine={false} + axisLine={false} + interval={5} + /> + fmtCompact(v)} + tick={{ fill: NB.mut, fontSize: 14 }} + tickLine={false} + axisLine={false} + width={52} + /> + { + if (!active || !payload?.length) return null; + const row = payload[0].payload as { date: string; total: number }; + return ( +
+
+ {new Date(row.date).toLocaleDateString([], { + weekday: "short", + month: "short", + day: "numeric", + })} +
+
{fmtMoney(row.total)}
+
+ ); + }} + /> + +
+
+
+ {/* lifecycle-phase mix */} + {activePhases.length > 0 && ( +
+
+ {activePhases.map((p) => ( +
+ ))} +
+
+ {activePhases.slice(0, 5).map((p) => ( + + + {PHASE_CONFIG[p.phase]?.label || p.phase} + + ))} +
+
+ )} +
+ + {/* business + ops band */} +
+ {band.map((cell, i) => ( +
+
+ {cell.label} +
+
+ {cell.value} +
+
+ {cell.sub} +
+
+ ))} +
+ + {/* live event feed */} +
+
+
+ {[...events].reverse().map((event) => { + const meta = EVENT_META[event.metric_id]; + if (!meta) return null; + const d = event.event_properties; + let name = ""; + let detail: React.ReactNode = ""; + if (event.metric_id === METRIC_IDS.PLACED_ORDER) { + name = d.ShippingName ?? ""; + const flags = [ + d.IsOnHold && "On Hold", + d.StillOwes && "Owes", + d.LocalPickup && "Local", + d.HasPreorder && "Pre-order", + ].filter(Boolean); + detail = ( + <> + #{d.OrderId} · {fmtEventMoney(d.TotalAmount)} + {flags.length > 0 && ` · ${flags.join(" · ")}`} + + ); + } else if (event.metric_id === METRIC_IDS.SHIPPED_ORDER) { + name = d.ShippingName ?? ""; + detail = `#${d.OrderId} · ${shipMethod(d.ShipMethod)}${d.ShippedBy ? ` · by ${d.ShippedBy}` : ""}`; + } else if (event.metric_id === METRIC_IDS.ACCOUNT_CREATED) { + name = `${d.FirstName ?? ""} ${d.LastName ?? ""}`.trim(); + detail = d.EmailAddress; + } else if (event.metric_id === METRIC_IDS.CANCELED_ORDER) { + name = d.ShippingName ?? ""; + detail = ( + <> + #{d.OrderId} · {fmtEventMoney(d.TotalAmount)} + {d.CancelReason ? ` · ${d.CancelReason}` : ""} + + ); + } else if (event.metric_id === METRIC_IDS.PAYMENT_REFUNDED) { + name = d.ShippingName ?? ""; + detail = ( + <> + #{d.FromOrder} · {fmtEventMoney(d.PaymentAmount)} + {d.PaymentName ? ` · ${d.PaymentName}` : ""} + + ); + } else if (event.metric_id === METRIC_IDS.NEW_BLOG_POST) { + name = d.title ?? ""; + detail = d.description; + } + return ( + +
+
+ + + {meta.label} + + {event.datetime && ( + + {new Date(event.datetime).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + })} + + )} +
+
{name || "—"}
+
+ {detail} +
+
+
+ ); + })} +
+
+ {/* edge fades */} +
+
+
+
+ ); +}; + +export default NightboardSmall; diff --git a/inventory/src/components/dashboard/shared/BusinessRangeSelect.tsx b/inventory/src/components/dashboard/shared/BusinessRangeSelect.tsx index ab56f51..c87a2dd 100644 --- a/inventory/src/components/dashboard/shared/BusinessRangeSelect.tsx +++ b/inventory/src/components/dashboard/shared/BusinessRangeSelect.tsx @@ -31,7 +31,7 @@ export interface BusinessRangeSelectProps { onValueChange: (value: string) => void; /** Preset options. Defaults to the canonical TIME_RANGES list. */ options?: RangeOption[]; - /** Merged onto the SelectTrigger (default sizing is w-[130px] h-9). */ + /** Merged onto the SelectTrigger (default sizing is w-[130px] h-8). */ className?: string; placeholder?: string; id?: string; @@ -47,10 +47,16 @@ export function BusinessRangeSelect({ }: BusinessRangeSelectProps) { return (