update the orders page ui
This commit is contained in:
12
.claude/launch.json
Normal file
12
.claude/launch.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "tower",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
108
src/Layout.jsx
108
src/Layout.jsx
@@ -8,8 +8,7 @@ import {
|
||||
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
|
||||
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff, Megaphone,
|
||||
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
|
||||
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft, FolderOpen, Brain, Zap, Network, Cpu, Command, BadgeCheck,
|
||||
ChevronDown, ChevronRight
|
||||
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft, FolderOpen, Brain, Zap, Network, Cpu, Command, BadgeCheck
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -34,6 +33,7 @@ import RoleSwitcher from "./components/dev/RoleSwitcher";
|
||||
import NotificationPanel from "./components/notifications/NotificationPanel";
|
||||
import { NotificationEngine } from "./components/notifications/NotificationEngine";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import SidebarNav, { NavigationMenu, useSidebarCollapsed } from "./components/navigation/SidebarNav";
|
||||
import OwliverProvider from "./components/owliver/OwliverProvider";
|
||||
import OwliverShell from "./components/owliver/OwliverShell";
|
||||
|
||||
@@ -381,91 +381,13 @@ const secondaryItemsFor = (userRole) =>
|
||||
.flatMap((group) => group.items)
|
||||
.filter((item) => !EXISTING_MENU_URLS.has(item.url));
|
||||
|
||||
/**
|
||||
* Category-first sidebar: the top level is the work areas, and a category's
|
||||
* pages appear only once it is open. One category is open at a time, so the
|
||||
* sidebar stays about as tall as its category list however many pages a role
|
||||
* has.
|
||||
*/
|
||||
function NavigationMenu({ location, userRole, closeSheet }) {
|
||||
const categories = primaryCategoriesFor(userRole);
|
||||
const currentPath = location.pathname;
|
||||
|
||||
const activeCategory =
|
||||
categories.find((group) => group.items.some((item) => item.url === currentPath))
|
||||
?.category || null;
|
||||
|
||||
const [openCategory, setOpenCategory] = React.useState(activeCategory);
|
||||
|
||||
// Navigating re-opens whichever category owns the new page, so the current
|
||||
// page is never hidden behind a closed category. Between navigations the
|
||||
// user's own choice wins — this only re-syncs when the route actually
|
||||
// changes. (React's documented "adjust state when a prop changes" pattern:
|
||||
// it re-renders immediately rather than painting a stale frame.)
|
||||
const syncedPath = React.useRef(currentPath);
|
||||
if (syncedPath.current !== currentPath) {
|
||||
syncedPath.current = currentPath;
|
||||
setOpenCategory(activeCategory);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="space-y-1">
|
||||
{categories.map((categoryGroup) => {
|
||||
const isOpen = openCategory === categoryGroup.category;
|
||||
const holdsActivePage = activeCategory === categoryGroup.category;
|
||||
|
||||
return (
|
||||
<div key={categoryGroup.category}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenCategory(isOpen ? null : categoryGroup.category)}
|
||||
aria-expanded={isOpen}
|
||||
className={`w-full flex items-center justify-between gap-2 px-4 py-2 text-xs sm:text-sm font-bold uppercase tracking-wider transition-colors focus-visible:outline-none ${
|
||||
holdsActivePage
|
||||
? 'text-[#0039A6]'
|
||||
: 'text-slate-600 hover:text-[#0039A6]'
|
||||
}`}
|
||||
>
|
||||
<span>{categoryGroup.category}</span>
|
||||
{isOpen
|
||||
? <ChevronDown className="w-4 h-4" />
|
||||
: <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="space-y-0.5 mt-0.5">
|
||||
{categoryGroup.items.map((item) => {
|
||||
const isActive = currentPath === item.url;
|
||||
return (
|
||||
<Link
|
||||
key={item.title}
|
||||
to={item.url}
|
||||
onClick={closeSheet}
|
||||
className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-[#0039A6] text-white shadow-md font-medium'
|
||||
: 'text-slate-600 hover:bg-[#C8DBDC]/30 hover:text-[#3C4F5C]'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span className="text-sm">{item.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutChrome({ children }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [showNotifications, setShowNotifications] = React.useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||
// Remembered across navigation and reloads; see useSidebarCollapsed.
|
||||
const [sidebarCollapsed, toggleSidebar] = useSidebarCollapsed();
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-layout'],
|
||||
@@ -494,6 +416,7 @@ function LayoutChrome({ children }) {
|
||||
const userRole = localStorage.getItem("dev_role_override") || user?.user_role || user?.role || "admin";
|
||||
// Categories that left the sidebar now live in the "More options" menu.
|
||||
const secondaryNavItems = secondaryItemsFor(userRole);
|
||||
const navCategories = primaryCategoriesFor(userRole);
|
||||
const userName = user?.full_name || user?.email || "User";
|
||||
const userInitial = userName.charAt(0).toUpperCase();
|
||||
|
||||
@@ -591,7 +514,11 @@ function LayoutChrome({ children }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<NavigationMenu location={location} userRole={userRole} closeSheet={() => setMobileMenuOpen(false)} />
|
||||
<NavigationMenu
|
||||
categories={navCategories}
|
||||
currentPath={location.pathname}
|
||||
onNavigate={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3 border-t border-slate-200">
|
||||
<Button variant="ghost" className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {handleLogout(); setMobileMenuOpen(false);}}>
|
||||
@@ -719,12 +646,15 @@ function LayoutChrome({ children }) {
|
||||
<div className="krow-shell-body flex flex-1 min-h-0 overflow-hidden min-w-0">
|
||||
{/* The rail itself never moves: it is a track in a row that is exactly
|
||||
viewport-minus-header tall. Only the navigation inside it scrolls, and
|
||||
only when the nav is taller than that. */}
|
||||
<aside className="hidden lg:flex lg:flex-col w-64 flex-shrink-0 min-h-0 bg-white border-r border-slate-200 shadow-sm">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3">
|
||||
<NavigationMenu location={location} userRole={userRole} closeSheet={() => {}} />
|
||||
</div>
|
||||
</aside>
|
||||
only when the nav is taller than that. Collapsing changes the track's
|
||||
width, so <main> and the Owliver panel re-flow rather than being
|
||||
covered. */}
|
||||
<SidebarNav
|
||||
categories={navCategories}
|
||||
currentPath={location.pathname}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={toggleSidebar}
|
||||
/>
|
||||
|
||||
{/* The page's scrollport — the one place vertical scrolling happens.
|
||||
`min-w-0`/`min-h-0` are both required: without them a wide table or a
|
||||
|
||||
373
src/components/navigation/SidebarNav.jsx
Normal file
373
src/components/navigation/SidebarNav.jsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
BarChart3, Briefcase, Building2, Calendar, ChevronRight,
|
||||
ChevronsLeft, ChevronsRight, Clipboard, DollarSign, GraduationCap, HelpCircle,
|
||||
LayoutDashboard, Layers, Network, Shield, Store, TrendingUp, UserCheck, Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
/**
|
||||
* KROW sidebar navigation
|
||||
* -----------------------
|
||||
* The same navigation in two widths. Expanded, it is category headers with
|
||||
* their pages underneath; collapsed, it is the same hierarchy reduced to icons —
|
||||
* the open category still shows its pages, the closed ones show one icon each,
|
||||
* and a tooltip carries whatever the label no longer can.
|
||||
*
|
||||
* expanded (256px) collapsed (72px)
|
||||
* ┌────────────────────┐ ┌────────┐
|
||||
* │ OVERVIEW ˅ │ │ ⌘ │ ← open category's pages
|
||||
* │ ⌘ KROW Command │ │ ▦ │
|
||||
* │ ▦ Home │ │ ─── │
|
||||
* │ OPERATIONS › │ │ ▤ │ ← closed categories
|
||||
* └────────────────────┘ └────────┘
|
||||
*
|
||||
* Nothing about the routes changes: `roleNavigationMap` in Layout.jsx is still
|
||||
* the single source of what a role can reach, and this component only decides
|
||||
* how that list is presented. The open category always follows the route, so a
|
||||
* page is never hidden behind a collapsed section.
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ icons */
|
||||
|
||||
/**
|
||||
* One icon per work area, from the same lucide family the page links already
|
||||
* use. A category with no entry falls back to `Layers` rather than borrowing a
|
||||
* meaning it doesn't have.
|
||||
*/
|
||||
const CATEGORY_ICONS = {
|
||||
Overview: LayoutDashboard,
|
||||
Operations: Briefcase,
|
||||
Management: Building2,
|
||||
Workforce: Users,
|
||||
Finance: DollarSign,
|
||||
Analytics: BarChart3,
|
||||
Network: Network,
|
||||
Compliance: Shield,
|
||||
Orders: Clipboard,
|
||||
Marketplace: Store,
|
||||
Team: UserCheck,
|
||||
Business: Briefcase,
|
||||
Work: Calendar,
|
||||
Growth: TrendingUp,
|
||||
Support: HelpCircle,
|
||||
Onboarding: GraduationCap,
|
||||
};
|
||||
|
||||
const categoryIcon = (name) => CATEGORY_ICONS[name] || Layers;
|
||||
|
||||
/* ----------------------------------------------------------- route match */
|
||||
|
||||
/**
|
||||
* `createPageUrl()` lower-cases the paths it builds ("/events") while App.jsx
|
||||
* declares them capitalised ("/Events"); React Router matches either, so both
|
||||
* spellings reach the same page depending on whether the user clicked a link or
|
||||
* typed/refreshed the URL. Comparing the raw strings therefore left a deep-linked
|
||||
* page with no highlighted item and no open section. Normalise both sides.
|
||||
*/
|
||||
const samePath = (a, b) =>
|
||||
String(a || "").toLowerCase().replace(/\/+$/, "") ===
|
||||
String(b || "").toLowerCase().replace(/\/+$/, "");
|
||||
|
||||
/* ----------------------------------------------------------- persistence */
|
||||
|
||||
const STORAGE_KEY = "krow.sidebar.collapsed";
|
||||
/** Below this the rail starts collapsed: the page and Owliver need the width. */
|
||||
const AUTO_COLLAPSE_BELOW = 1280;
|
||||
|
||||
/**
|
||||
* Collapsed/expanded, remembered across navigation and reloads.
|
||||
* A stored choice always wins; with no choice yet, the viewport decides once.
|
||||
*/
|
||||
export function useSidebarCollapsed() {
|
||||
const [collapsed, setCollapsed] = React.useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "1") return true;
|
||||
if (stored === "0") return false;
|
||||
} catch {
|
||||
/* private mode — fall through to the viewport default */
|
||||
}
|
||||
return window.innerWidth < AUTO_COLLAPSE_BELOW;
|
||||
});
|
||||
|
||||
const toggle = React.useCallback(() => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
/* the choice still holds for this session */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return [collapsed, toggle];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- primitives */
|
||||
|
||||
const FOCUS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/40 focus-visible:ring-offset-1 focus-visible:ring-offset-white";
|
||||
|
||||
/** A tooltip that only exists while the rail is collapsed. */
|
||||
function Hint({ label, show, children }) {
|
||||
if (!show) return children;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
className="border border-slate-700/40 bg-[#1C323E] px-2.5 py-1.5 text-[12px] font-medium text-white shadow-lg"
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** One page link, in either width. */
|
||||
function NavItem({ item, isActive, collapsed, onNavigate }) {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Hint label={item.title} show={collapsed}>
|
||||
<Link
|
||||
to={item.url}
|
||||
onClick={onNavigate}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
aria-label={collapsed ? item.title : undefined}
|
||||
title={undefined}
|
||||
className={`relative flex items-center rounded-lg transition-colors duration-150 motion-reduce:transition-none ${FOCUS} ${
|
||||
collapsed ? "h-9 w-9 justify-center" : "gap-2.5 px-2.5 py-[7px]"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-[#0039A6]/[0.07] font-semibold text-[#1C323E]"
|
||||
: "font-medium text-slate-600 hover:bg-slate-100/80 hover:text-[#1C323E]"
|
||||
}`}
|
||||
>
|
||||
{/* The active marker: a short rule on the rail's edge, not a filled block. */}
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute rounded-full bg-[#0039A6] ${
|
||||
collapsed ? "-left-[9px] top-1.5 h-6 w-[3px]" : "-left-2 top-1.5 bottom-1.5 w-[3px]"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
className={`h-4 w-4 flex-shrink-0 ${isActive ? "text-[#0039A6]" : "text-slate-400"}`}
|
||||
/>
|
||||
{!collapsed && <span className="truncate text-[13.5px] leading-tight">{item.title}</span>}
|
||||
</Link>
|
||||
</Hint>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- menu */
|
||||
|
||||
/**
|
||||
* The navigation itself. Used at both widths and inside the mobile drawer,
|
||||
* where it always renders expanded.
|
||||
*/
|
||||
export function NavigationMenu({ categories, currentPath, collapsed = false, onNavigate }) {
|
||||
const activeCategory =
|
||||
categories.find((group) => group.items.some((item) => samePath(item.url, currentPath)))
|
||||
?.category || null;
|
||||
|
||||
const [openCategory, setOpenCategory] = React.useState(activeCategory);
|
||||
|
||||
// Navigating re-opens whichever category owns the new page, so the current
|
||||
// page is never hidden behind a closed category. Between navigations the
|
||||
// user's own choice wins — this only re-syncs when the route actually
|
||||
// changes. (React's documented "adjust state when a prop changes" pattern:
|
||||
// it re-renders immediately rather than painting a stale frame.)
|
||||
const syncedPath = React.useRef(currentPath);
|
||||
if (syncedPath.current !== currentPath) {
|
||||
syncedPath.current = currentPath;
|
||||
setOpenCategory(activeCategory);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Main" className={collapsed ? "space-y-1" : "space-y-0.5"}>
|
||||
{categories.map((group) => {
|
||||
const isOpen = openCategory === group.category;
|
||||
const holdsActivePage = activeCategory === group.category;
|
||||
const CategoryIcon = categoryIcon(group.category);
|
||||
const sectionId = `nav-section-${group.category.replace(/\s+/g, "-").toLowerCase()}`;
|
||||
const toggle = () => setOpenCategory(isOpen ? null : group.category);
|
||||
|
||||
/* --- collapsed: an open category becomes its pages, a closed one
|
||||
stays a single icon, so the hierarchy survives the width. --- */
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div key={group.category} className="flex flex-col items-center gap-1">
|
||||
{isOpen ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="mt-1 h-px w-6 flex-shrink-0 bg-slate-200" />
|
||||
|
||||
{/* The open category keeps its own icon at this width too, so
|
||||
the icons below it are visibly *its* pages and there is
|
||||
still something to click to close it. */}
|
||||
<Hint label={group.category} show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded
|
||||
aria-controls={sectionId}
|
||||
aria-label={`${group.category} navigation`}
|
||||
className={`flex h-7 w-9 items-center justify-center rounded-lg text-[#0039A6] transition-colors duration-150 motion-reduce:transition-none hover:bg-slate-100/80 ${FOCUS}`}
|
||||
>
|
||||
<CategoryIcon className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
</Hint>
|
||||
|
||||
<div
|
||||
id={sectionId}
|
||||
role="group"
|
||||
aria-label={group.category}
|
||||
className="flex flex-col items-center gap-1"
|
||||
>
|
||||
{group.items.map((item) => (
|
||||
<NavItem
|
||||
key={item.title}
|
||||
item={item}
|
||||
isActive={samePath(item.url, currentPath)}
|
||||
collapsed
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span aria-hidden="true" className="mb-1 h-px w-6 flex-shrink-0 bg-slate-200" />
|
||||
</>
|
||||
) : (
|
||||
<Hint label={group.category} show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={false}
|
||||
aria-controls={sectionId}
|
||||
aria-label={`${group.category} navigation`}
|
||||
className={`relative flex h-9 w-9 items-center justify-center rounded-lg transition-colors duration-150 motion-reduce:transition-none ${FOCUS} ${
|
||||
holdsActivePage
|
||||
? "bg-[#0039A6]/[0.07] text-[#0039A6]"
|
||||
: "text-slate-500 hover:bg-slate-100/80 hover:text-[#1C323E]"
|
||||
}`}
|
||||
>
|
||||
<CategoryIcon className="h-[18px] w-[18px]" />
|
||||
{holdsActivePage && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#0039A6]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Hint>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- expanded --- */
|
||||
return (
|
||||
<div key={group.category}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={sectionId}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left transition-colors duration-150 motion-reduce:transition-none ${FOCUS} ${
|
||||
holdsActivePage
|
||||
? "text-[#0039A6]"
|
||||
: "text-slate-500 hover:bg-slate-100/60 hover:text-[#1C323E]"
|
||||
}`}
|
||||
>
|
||||
<CategoryIcon
|
||||
className={`h-4 w-4 flex-shrink-0 ${holdsActivePage ? "text-[#0039A6]" : "text-slate-400"}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-bold uppercase tracking-[0.08em]">
|
||||
{group.category}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={`h-3.5 w-3.5 flex-shrink-0 text-slate-400 transition-transform duration-200 motion-reduce:transition-none ${
|
||||
isOpen ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div id={sectionId} role="group" aria-label={group.category} className="mb-1 ml-[19px] mt-0.5 space-y-0.5 border-l border-slate-200 pl-2">
|
||||
{group.items.map((item) => (
|
||||
<NavItem
|
||||
key={item.title}
|
||||
item={item}
|
||||
isActive={samePath(item.url, currentPath)}
|
||||
collapsed={false}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ rail */
|
||||
|
||||
/**
|
||||
* The desktop rail: a track in the shell's body row that changes width rather
|
||||
* than overlaying the page, so <main> and the Owliver panel simply re-flow.
|
||||
*/
|
||||
export default function SidebarNav({ categories, currentPath, collapsed, onToggle }) {
|
||||
const ChevronIcon = collapsed ? ChevronsRight : ChevronsLeft;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={120} skipDelayDuration={300}>
|
||||
<aside
|
||||
data-collapsed={collapsed ? "true" : "false"}
|
||||
className={`hidden min-h-0 flex-shrink-0 flex-col border-r border-slate-200 bg-white shadow-sm transition-[width] duration-200 ease-out motion-reduce:transition-none lg:flex ${
|
||||
collapsed ? "w-[72px]" : "w-64"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex-1 min-h-0 overflow-y-auto overflow-x-hidden py-3 ${collapsed ? "px-3" : "px-3"}`}>
|
||||
<NavigationMenu
|
||||
categories={categories}
|
||||
currentPath={currentPath}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The collapse control lives in the rail, at the end of the navigation
|
||||
it controls — not floating over the page. */}
|
||||
<div className="flex-shrink-0 border-t border-slate-200 p-2">
|
||||
<Hint label="Expand sidebar" show={collapsed}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
aria-pressed={collapsed}
|
||||
className={`flex items-center rounded-lg text-slate-500 transition-colors duration-150 motion-reduce:transition-none hover:bg-slate-100/80 hover:text-[#1C323E] ${FOCUS} ${
|
||||
collapsed ? "mx-auto h-9 w-9 justify-center" : "w-full gap-2.5 px-2.5 py-2"
|
||||
}`}
|
||||
>
|
||||
<ChevronIcon className="h-4 w-4 flex-shrink-0" />
|
||||
{!collapsed && (
|
||||
<span className="truncate text-[12.5px] font-medium">Collapse sidebar</span>
|
||||
)}
|
||||
</button>
|
||||
</Hint>
|
||||
</div>
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
174
src/components/orders/orderPresentation.jsx
Normal file
174
src/components/orders/orderPresentation.jsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { AlertTriangle, CheckCircle2, Clock, FileText, Users, X, Zap } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Order presentation primitives — KROW Control Tower
|
||||
* --------------------------------------------------
|
||||
* One source of truth for how an order *looks* wherever it appears: the Order
|
||||
* Management table and Owliver's operational cards both render from here, so a
|
||||
* status pill or a staffing meter can never drift between the page and the
|
||||
* copilot reading that same page.
|
||||
*
|
||||
* Presentation only. Nothing here computes business state — status strings and
|
||||
* headcounts arrive already calculated by the page (Events.jsx) or by the
|
||||
* Owliver page registry, which mirror each other deliberately.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------- status pill */
|
||||
|
||||
/**
|
||||
* Compact status tokens. Colour carries meaning and nothing else: the pill is a
|
||||
* quiet tinted chip with a solid dot, not a saturated block, so a table of nine
|
||||
* orders does not read as a paint chart.
|
||||
*/
|
||||
export const STATUS_TOKENS = {
|
||||
"Draft": { label: "Draft", dot: "bg-slate-400", text: "text-slate-600", bg: "bg-slate-50", border: "border-slate-200", icon: FileText },
|
||||
"Pending": { label: "Pending", dot: "bg-amber-500", text: "text-amber-800", bg: "bg-amber-50", border: "border-amber-200", icon: Clock },
|
||||
"Partial Staffed": { label: "Partial", dot: "bg-orange-500", text: "text-orange-800", bg: "bg-orange-50", border: "border-orange-200", icon: AlertTriangle },
|
||||
"Fully Staffed": { label: "Fully staffed", dot: "bg-emerald-500", text: "text-emerald-800", bg: "bg-emerald-50", border: "border-emerald-200", icon: CheckCircle2 },
|
||||
"Active": { label: "Active", dot: "bg-[#0039A6]", text: "text-[#0039A6]", bg: "bg-blue-50", border: "border-blue-200", icon: Users },
|
||||
"Completed": { label: "Completed", dot: "bg-slate-300", text: "text-slate-500", bg: "bg-slate-50", border: "border-slate-200", icon: CheckCircle2 },
|
||||
"Canceled": { label: "Canceled", dot: "bg-red-500", text: "text-red-700", bg: "bg-red-50", border: "border-red-200", icon: X },
|
||||
};
|
||||
|
||||
const RAPID_TOKEN = {
|
||||
label: "Rapid",
|
||||
dot: "bg-red-500",
|
||||
text: "text-red-700",
|
||||
bg: "bg-red-50",
|
||||
border: "border-red-200",
|
||||
icon: Zap,
|
||||
};
|
||||
|
||||
const FALLBACK_TOKEN = STATUS_TOKENS["Draft"];
|
||||
|
||||
export const statusToken = (status, isRapid) =>
|
||||
isRapid ? RAPID_TOKEN : STATUS_TOKENS[status] || { ...FALLBACK_TOKEN, label: status || "Unknown" };
|
||||
|
||||
/**
|
||||
* The status of one order, as a compact pill.
|
||||
* `hasConflicts` adds a small warning marker rather than a second badge — a
|
||||
* conflict is an annotation on the status, not a status of its own.
|
||||
*/
|
||||
export function OrderStatusPill({ status, isRapid = false, hasConflicts = false, className = "" }) {
|
||||
const token = statusToken(status, isRapid);
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 ${className}`}>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-[3px] text-[11px] font-semibold leading-none whitespace-nowrap ${token.bg} ${token.border} ${token.text}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${token.dot}`} />
|
||||
{token.label}
|
||||
</span>
|
||||
{hasConflicts && (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-orange-500" aria-label="Scheduling conflict" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- staffing */
|
||||
|
||||
/**
|
||||
* How urgent an order's staffing gap is.
|
||||
* full — at requested headcount
|
||||
* high — under half filled, or nobody assigned at all
|
||||
* medium — filled, but short
|
||||
* none — nothing requested yet (a draft with no roles priced in)
|
||||
*/
|
||||
export function severityOf(assigned, requested) {
|
||||
const a = Number(assigned) || 0;
|
||||
const r = Number(requested) || 0;
|
||||
if (r <= 0) return "none";
|
||||
if (a >= r) return "full";
|
||||
return a / r < 0.5 ? "high" : "medium";
|
||||
}
|
||||
|
||||
export const SEVERITY_TOKENS = {
|
||||
high: { label: "High", fill: "bg-orange-500", track: "bg-orange-100", text: "text-orange-700", bg: "bg-orange-50", border: "border-orange-200", rail: "bg-orange-500" },
|
||||
medium: { label: "Medium", fill: "bg-amber-500", track: "bg-amber-100", text: "text-amber-700", bg: "bg-amber-50", border: "border-amber-200", rail: "bg-amber-400" },
|
||||
full: { label: "On track", fill: "bg-emerald-500", track: "bg-emerald-100", text: "text-emerald-700", bg: "bg-emerald-50", border: "border-emerald-200", rail: "bg-transparent" },
|
||||
none: { label: "Not sized", fill: "bg-slate-300", track: "bg-slate-100", text: "text-slate-500", bg: "bg-slate-50", border: "border-slate-200", rail: "bg-transparent" },
|
||||
};
|
||||
|
||||
export const fillPercent = (assigned, requested) => {
|
||||
const a = Number(assigned) || 0;
|
||||
const r = Number(requested) || 0;
|
||||
if (r <= 0) return 0;
|
||||
return Math.min(100, Math.round((a / r) * 100));
|
||||
};
|
||||
|
||||
const SEGMENTS = 10;
|
||||
|
||||
/**
|
||||
* `4 / 9` · `44%` · ▮▮▮▮▯▯▯▯▯▯
|
||||
*
|
||||
* The ratio is the fact, the percentage is the read, and the segmented track is
|
||||
* the glance. Segments rather than a smooth bar: ten discrete blocks are
|
||||
* countable at a glance and land on the same visual rhythm in a dense table row
|
||||
* as they do in a copilot card.
|
||||
*/
|
||||
export function StaffingMeter({
|
||||
assigned,
|
||||
requested,
|
||||
size = "md",
|
||||
align = "start",
|
||||
showFigures = true,
|
||||
className = "",
|
||||
}) {
|
||||
const a = Number(assigned) || 0;
|
||||
const r = Number(requested) || 0;
|
||||
const pct = fillPercent(a, r);
|
||||
const severity = severityOf(a, r);
|
||||
const token = SEVERITY_TOKENS[severity];
|
||||
const litSegments = r > 0 ? Math.max(a > 0 ? 1 : 0, Math.round((pct / 100) * SEGMENTS)) : 0;
|
||||
|
||||
const figures = size === "sm" ? "text-[11px]" : "text-[12.5px]";
|
||||
const trackH = size === "sm" ? "h-[3px]" : "h-1";
|
||||
|
||||
return (
|
||||
<div className={`min-w-0 ${align === "center" ? "text-center" : ""} ${className}`}>
|
||||
{showFigures && (
|
||||
<div
|
||||
className={`flex items-baseline gap-1.5 ${figures} ${align === "center" ? "justify-center" : ""}`}
|
||||
>
|
||||
<span className="font-semibold tabular-nums text-slate-900">
|
||||
{a} <span className="font-normal text-slate-400">/</span> {r || "—"}
|
||||
</span>
|
||||
<span className={`text-[11px] font-medium tabular-nums ${severity === "full" ? "text-emerald-600" : "text-slate-500"}`}>
|
||||
{r > 0 ? `${pct}%` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`mt-1.5 flex gap-[2px] ${align === "center" ? "justify-center" : ""}`}
|
||||
role="img"
|
||||
aria-label={r > 0 ? `${a} of ${r} staffed, ${pct} percent` : "No headcount requested"}
|
||||
>
|
||||
{Array.from({ length: SEGMENTS }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`${trackH} flex-1 rounded-[1px] transition-colors duration-200 ${
|
||||
i < litSegments ? token.fill : "bg-slate-200/70"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- dates */
|
||||
|
||||
/** "Aug 17" / "6:00 PM – 11:30 PM" formatting shared by the table and Owliver. */
|
||||
export const to12Hour = (time24) => {
|
||||
if (!time24) return null;
|
||||
const [rawH, m] = String(time24).split(":");
|
||||
const h = Number(rawH);
|
||||
if (Number.isNaN(h)) return time24;
|
||||
const suffix = h >= 12 ? "PM" : "AM";
|
||||
return `${h % 12 || 12}:${m} ${suffix}`;
|
||||
};
|
||||
|
||||
export default OrderStatusPill;
|
||||
@@ -1,369 +0,0 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Loader2,
|
||||
X,
|
||||
Clock,
|
||||
MapPin,
|
||||
Sparkles,
|
||||
Building2,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { stripContext } from "@/lib/owliver/context";
|
||||
import { extractActions, isValidRoute } from "@/lib/owliver/actions";
|
||||
|
||||
/** Compact, non-technical progress line for an agent tool call. */
|
||||
function ToolLine({ toolCall }) {
|
||||
const status = toolCall.status || "pending";
|
||||
const running = ["pending", "running", "in_progress"].includes(status);
|
||||
const failed = ["failed", "error"].includes(status);
|
||||
const label = toolCall.display_projection?.label || toolCall.name || "Working";
|
||||
const activeLabel = toolCall.display_projection?.active_label || `${label}…`;
|
||||
const errorLabel = toolCall.display_projection?.error_label || `${label} failed`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[12px] py-0.5" style={{ color: "var(--ow-ink-subtle)" }}>
|
||||
{running ? <Loader2 className="w-3 h-3 animate-spin flex-shrink-0" />
|
||||
: failed ? <X className="w-3 h-3 flex-shrink-0" style={{ color: "var(--ow-danger)" }} />
|
||||
: <Check className="w-3 h-3 flex-shrink-0" />}
|
||||
<span className="truncate">{running ? activeLabel : failed ? errorLabel : label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function extractNodeText(node) {
|
||||
if (!node) return "";
|
||||
if (typeof node === "string") return node;
|
||||
if (typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map(extractNodeText).join("");
|
||||
if (node.props?.children) return extractNodeText(node.props.children);
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseListItemText(text) {
|
||||
const rawClean = String(text || "").trim();
|
||||
|
||||
let id = "";
|
||||
const idMatch = rawClean.match(/\[id:([^\]]+)\]/);
|
||||
if (idMatch) {
|
||||
id = idMatch[1];
|
||||
}
|
||||
const clean = rawClean.replace(/\[id:[^\]]+\]/g, "").trim();
|
||||
|
||||
// Ratio check e.g. "3/10 staffed" or "9/20 staffed" or "3/10"
|
||||
const ratioMatch = clean.match(/(\d+)\/(\d+)\s*(staffed|filled|seats)?/i);
|
||||
if (ratioMatch) {
|
||||
const assigned = parseInt(ratioMatch[1], 10);
|
||||
const requested = parseInt(ratioMatch[2], 10);
|
||||
const pct = requested > 0 ? Math.min(100, Math.round((assigned / requested) * 100)) : 0;
|
||||
|
||||
let titlePart = clean.replace(/·|\b\d+\/\d+\s*(staffed|filled|seats)?/gi, "").trim();
|
||||
let hub = "";
|
||||
let dateTime = "";
|
||||
|
||||
const hubMatch = titlePart.match(/\(([^)]+)\)/);
|
||||
if (hubMatch) {
|
||||
hub = hubMatch[1];
|
||||
titlePart = titlePart.replace(/\([^)]+\)/, "").trim();
|
||||
}
|
||||
|
||||
const commaParts = titlePart.split(",");
|
||||
if (commaParts.length > 1) {
|
||||
titlePart = commaParts[0].trim();
|
||||
dateTime = commaParts.slice(1).join(",").trim();
|
||||
}
|
||||
|
||||
let company = titlePart;
|
||||
let eventName = "";
|
||||
|
||||
if (titlePart.includes("—")) {
|
||||
const parts = titlePart.split("—");
|
||||
company = parts[0].trim();
|
||||
eventName = parts.slice(1).join("—").trim();
|
||||
} else if (titlePart.includes(" - ")) {
|
||||
const parts = titlePart.split(" - ");
|
||||
company = parts[0].trim();
|
||||
eventName = parts.slice(1).join(" - ").trim();
|
||||
}
|
||||
|
||||
return {
|
||||
type: "order_card",
|
||||
id,
|
||||
company,
|
||||
eventName,
|
||||
hub,
|
||||
dateTime,
|
||||
assigned,
|
||||
requested,
|
||||
pct,
|
||||
raw: clean,
|
||||
};
|
||||
}
|
||||
|
||||
// Step flow check e.g. "Step 1: ...", "1. ...", "First: ..."
|
||||
const stepMatch = clean.match(/^(step\s*\d+|\d+[.)]|first|second|third|fourth|fifth)[\s:]*(.*)/i);
|
||||
if (stepMatch) {
|
||||
return {
|
||||
type: "flow_step",
|
||||
stepLabel: stepMatch[1],
|
||||
content: stepMatch[2] || clean,
|
||||
raw: clean,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "generic_card",
|
||||
raw: clean,
|
||||
};
|
||||
}
|
||||
|
||||
export default function OwliverAnswer({ message }) {
|
||||
const navigate = useNavigate();
|
||||
const content = stripContext(message.content);
|
||||
|
||||
if (message.role === "user") {
|
||||
if (!content) return null;
|
||||
return (
|
||||
<p className="text-[13.5px] leading-[1.55] whitespace-pre-wrap m-0">
|
||||
{content}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const actions = extractActions(content);
|
||||
const toolCalls = message.tool_calls || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="mb-2 space-y-0.5">
|
||||
{toolCalls.map((tc, i) => <ToolLine key={i} toolCall={tc} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content && (
|
||||
<div className="ow-prose text-[13.5px] min-w-0">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
ul: ({ children }) => <div className="my-3 space-y-2.5 not-prose">{children}</div>,
|
||||
ol: ({ children }) => <div className="my-3 space-y-2.5 not-prose">{children}</div>,
|
||||
li: ({ children }) => {
|
||||
const rawText = extractNodeText(children);
|
||||
const parsed = parseListItemText(rawText);
|
||||
|
||||
if (parsed.type === "order_card") {
|
||||
const isFull = parsed.pct >= 100;
|
||||
const isLow = parsed.pct < 50;
|
||||
|
||||
const borderAccent = isFull
|
||||
? "border-l-emerald-500"
|
||||
: isLow
|
||||
? "border-l-amber-500"
|
||||
: "border-l-[#0039A6]";
|
||||
|
||||
const badgeStyle = isFull
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: isLow
|
||||
? "bg-amber-50 text-amber-700 border-amber-200"
|
||||
: "bg-blue-50 text-blue-700 border-blue-200";
|
||||
|
||||
const progressColor = isFull
|
||||
? "bg-emerald-500"
|
||||
: isLow
|
||||
? "bg-amber-500"
|
||||
: "bg-[#0039A6]";
|
||||
|
||||
return (
|
||||
<div className={`p-3.5 rounded-xl bg-white border border-slate-200/90 shadow-2xs hover:shadow-md transition-all space-y-2.5 border-l-4 ${borderAccent}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-xs font-extrabold text-slate-900 truncate flex items-center gap-1.5">
|
||||
<Building2 className="w-3.5 h-3.5 text-[#0039A6] shrink-0" />
|
||||
<span className="truncate">{parsed.company}</span>
|
||||
</h4>
|
||||
{parsed.eventName && (
|
||||
<p className="text-[11px] font-semibold text-slate-600 truncate mt-0.5">{parsed.eventName}</p>
|
||||
)}
|
||||
{parsed.hub && (
|
||||
<p className="text-[10px] text-slate-500 flex items-center gap-1 mt-0.5">
|
||||
<MapPin className="w-3 h-3 text-slate-400 shrink-0" />
|
||||
<span className="truncate">{parsed.hub}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full border text-[10px] font-bold shrink-0 flex items-center gap-1.5 ${badgeStyle}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${progressColor} animate-pulse`} />
|
||||
{parsed.assigned}/{parsed.requested} Staffed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-1 pt-0.5">
|
||||
<div className="flex justify-between text-[10px] font-bold text-slate-500">
|
||||
<span>Staffing Fill</span>
|
||||
<span className="text-slate-700">{parsed.pct}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${progressColor}`} style={{ width: `${parsed.pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-500 pt-1.5 border-t border-slate-100">
|
||||
<span className="flex items-center gap-1 font-medium truncate">
|
||||
<Clock className="w-3.5 h-3.5 text-blue-500 shrink-0" />
|
||||
<span className="truncate">{parsed.dateTime || "Scheduled Shift"}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (parsed.id) {
|
||||
navigate(`/EventDetail?id=${parsed.id}`);
|
||||
} else {
|
||||
navigate("/Events");
|
||||
}
|
||||
}}
|
||||
className="text-[#0039A6] font-bold flex items-center gap-1 hover:underline text-[11px] shrink-0 cursor-pointer"
|
||||
>
|
||||
<span>View Order</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.type === "flow_step") {
|
||||
return (
|
||||
<div className="flex items-start gap-3 p-3 rounded-xl bg-gradient-to-r from-blue-50/70 to-white border border-blue-100/90 shadow-2xs">
|
||||
<div className="w-6 h-6 rounded-full bg-[#0039A6] text-white font-black text-[10px] flex items-center justify-center shrink-0 shadow-xs uppercase">
|
||||
{parsed.stepLabel.slice(0, 3)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<p className="text-xs font-semibold text-slate-800 leading-relaxed">{parsed.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded-xl bg-white border border-slate-200/90 shadow-2xs hover:border-blue-300 transition-all flex items-start gap-2.5">
|
||||
<div className="w-2 h-2 rounded-full bg-[#0039A6] mt-1.5 shrink-0" />
|
||||
<div className="flex-1 text-xs text-slate-800 font-medium leading-normal">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
p: ({ children }) => {
|
||||
const text = extractNodeText(children);
|
||||
|
||||
// If paragraph mentions order risk, headcount ratio, or open seats
|
||||
const ratioMatch = text.match(/(\d+)\/(\d+)/);
|
||||
if (ratioMatch) {
|
||||
const assigned = parseInt(ratioMatch[1], 10);
|
||||
const requested = parseInt(ratioMatch[2], 10);
|
||||
const pct = requested > 0 ? Math.min(100, Math.round((assigned / requested) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div className="my-2.5 p-3.5 rounded-xl bg-white border border-amber-200 shadow-2xs border-l-4 border-l-amber-500 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-50 text-amber-800 border border-amber-200 text-[10px] font-extrabold uppercase tracking-wide">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-600" /> Order Attention
|
||||
</span>
|
||||
<p className="text-xs font-bold text-slate-900 mt-1.5 leading-snug">{text}</p>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 font-extrabold text-[11px] shrink-0">
|
||||
{assigned}/{requested}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] font-bold text-slate-500">
|
||||
<span>Staffing Fill</span>
|
||||
<span className="text-slate-700">{pct}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-amber-500 rounded-full transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end pt-1 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/Events")}
|
||||
className="text-[#0039A6] font-bold flex items-center gap-1 hover:underline text-[11px]"
|
||||
>
|
||||
<span>View Orders</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If paragraph is a summary metric callout
|
||||
if (/(\d+)\s*(orders|shifts|positions|candidates|seats)/i.test(text)) {
|
||||
return (
|
||||
<div className="p-3 rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50/60 border border-blue-200/70 text-[#0039A6] text-xs font-bold flex items-center gap-2 my-2 shadow-2xs">
|
||||
<Sparkles className="w-4 h-4 text-blue-600 shrink-0" />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <p className="mb-2 text-xs leading-relaxed text-slate-800">{children}</p>;
|
||||
},
|
||||
a: ({ href, children }) =>
|
||||
isValidRoute(href) ? (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => { e.preventDefault(); navigate(href); }}
|
||||
className="underline underline-offset-2 font-medium"
|
||||
style={{ color: "var(--ow-brand)" }}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
style={{ color: "var(--ow-brand)" }}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.route}
|
||||
type="button"
|
||||
onClick={() => navigate(action.route)}
|
||||
className="inline-flex items-center gap-1.5 max-w-full rounded-full border px-3 py-1.5 text-[12.5px] font-medium transition-colors hover:bg-black/[0.03] focus-visible:outline-none focus-visible:ring-2"
|
||||
style={{
|
||||
borderColor: "var(--ow-line-strong)",
|
||||
background: "var(--ow-surface-raised)",
|
||||
color: "var(--ow-ink)",
|
||||
outlineColor: "var(--ow-brand)",
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{action.label}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 flex-shrink-0" style={{ color: "var(--ow-brand)" }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
144
src/components/owliver/OwliverComposer.jsx
Normal file
144
src/components/owliver/OwliverComposer.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowUp, AlertCircle, Loader2, X } from "lucide-react";
|
||||
|
||||
/**
|
||||
* The composer.
|
||||
*
|
||||
* ┌──────────────────────────────────────┐
|
||||
* │ Ask Owliver about Saving Engine... │
|
||||
* │ │
|
||||
* │ ✦ Saving Engine [ ↑ ] │
|
||||
* └──────────────────────────────────────┘
|
||||
*
|
||||
* One field that starts at a single line and grows to about four before it
|
||||
* scrolls, a context chip that names what the question will be answered
|
||||
* against, and a send affordance that only lights up when there is something to
|
||||
* send. Enter sends, Shift+Enter breaks the line, and the whole thing is docked
|
||||
* — it never scrolls away with the conversation.
|
||||
*
|
||||
* While a turn is in flight the field stays mounted and readable but refuses
|
||||
* input, so a follow-up is never asked against half an answer.
|
||||
*/
|
||||
export default function OwliverComposer({
|
||||
onSubmit, placeholder, contextLabel, disabled, error, onRetry, autoFocus,
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [focused, setFocused] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Grow with the content, then scroll — a composer that swallows the panel is
|
||||
// worse than one that scrolls.
|
||||
useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 104)}px`;
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) inputRef.current?.focus();
|
||||
}, [autoFocus]);
|
||||
|
||||
// The field is re-armed the moment Owliver finishes, so the next question can
|
||||
// be typed without reaching for the mouse.
|
||||
useEffect(() => {
|
||||
if (!disabled) inputRef.current?.focus({ preventScroll: true });
|
||||
}, [disabled]);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const text = value.trim();
|
||||
if (!text || disabled) return;
|
||||
setValue("");
|
||||
onSubmit(text);
|
||||
}, [value, disabled, onSubmit]);
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
const canSend = value.trim().length > 0 && !disabled;
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 border-t border-slate-200 bg-white px-3 pb-3 pt-2.5">
|
||||
{error && (
|
||||
<div className="mb-2 flex items-start gap-2 rounded-lg border border-red-200 bg-red-50/70 px-2.5 py-2">
|
||||
<AlertCircle className="mt-[1px] h-3.5 w-3.5 flex-shrink-0 text-red-500" />
|
||||
<p className="min-w-0 flex-1 text-[11.5px] leading-snug text-red-800">{error}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="flex-shrink-0 text-[11.5px] font-semibold text-red-700 underline underline-offset-2"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); submit(); }}
|
||||
className={`rounded-[14px] border bg-white transition-all duration-150 ${
|
||||
focused
|
||||
? "border-[#0039A6]/45 shadow-[0_0_0_3px_rgba(0,57,166,0.10)]"
|
||||
: "border-slate-200 hover:border-slate-300"
|
||||
}`}
|
||||
>
|
||||
<div className="px-3 pt-2.5">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
placeholder={placeholder || "Ask Owliver anything..."}
|
||||
aria-label="Ask Owliver"
|
||||
className="block max-h-[104px] w-full resize-none border-0 bg-transparent p-0 text-[13px] leading-[1.5] text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-0 disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-2.5 pb-2 pt-2">
|
||||
{/* What the answer will be computed against. */}
|
||||
<span className="inline-flex min-w-0 items-center gap-1 rounded-md bg-slate-50 px-1.5 py-[3px] text-[10.5px] font-medium text-slate-500">
|
||||
<span aria-hidden="true" className="text-[#0039A6]">✦</span>
|
||||
<span className="truncate">{contextLabel || "Current page"}</span>
|
||||
</span>
|
||||
|
||||
{value && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setValue(""); inputRef.current?.focus(); }}
|
||||
aria-label="Clear"
|
||||
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="ml-auto hidden flex-shrink-0 text-[10px] text-slate-300 sm:block">
|
||||
{disabled ? "Working…" : "↵ send"}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSend}
|
||||
aria-label="Send"
|
||||
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg transition-all duration-150 ${
|
||||
canSend
|
||||
? "bg-[#0039A6] text-white hover:bg-[#002d85]"
|
||||
: "bg-slate-100 text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{disabled ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ArrowUp className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/components/owliver/OwliverContextBar.jsx
Normal file
59
src/components/owliver/OwliverContextBar.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, Database } from "lucide-react";
|
||||
|
||||
/**
|
||||
* What Owliver is answering from.
|
||||
*
|
||||
* ⛁ Using Saving Engine data ⌄
|
||||
* $285K saved · 27 opportunities · $8K at risk
|
||||
*
|
||||
* The claim on the first line is what makes the panel legible as page-bound;
|
||||
* the facts underneath are what make it credible, and they are read from the
|
||||
* page's own signals, so the bar can never state something the page does not.
|
||||
* It is expandable rather than always-open because context is reassurance, not
|
||||
* content — it should cost one line until someone asks.
|
||||
*/
|
||||
export default function OwliverContextBar({ label, facts = [], loading }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!label) return null;
|
||||
|
||||
const expandable = facts.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 border-b border-slate-200 bg-slate-50/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={expandable ? () => setOpen((v) => !v) : undefined}
|
||||
disabled={!expandable}
|
||||
aria-expanded={expandable ? open : undefined}
|
||||
className={`flex w-full items-center gap-1.5 px-3.5 py-1.5 text-left ${
|
||||
expandable ? "transition-colors hover:bg-slate-100/70" : "cursor-default"
|
||||
} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#0039A6]/30`}
|
||||
>
|
||||
<Database className="h-3 w-3 flex-shrink-0 text-[#0039A6]" />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold text-slate-600">
|
||||
{loading ? "Reading this page…" : label}
|
||||
</span>
|
||||
{expandable && (
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 flex-shrink-0 text-slate-400 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && facts.length > 0 && (
|
||||
<p className="ow-fade-in flex flex-wrap items-center gap-x-1.5 gap-y-0.5 px-3.5 pb-1.5 text-[11px] leading-tight">
|
||||
{facts.map((fact, i) => (
|
||||
<span key={`${fact.label}-${i}`} className="inline-flex items-center gap-1.5">
|
||||
{i > 0 && <span className="text-slate-300">·</span>}
|
||||
<span className={`font-semibold tabular-nums ${fact.tone === "risk" ? "text-orange-600" : "text-slate-900"}`}>
|
||||
{fact.value}
|
||||
</span>
|
||||
<span className="text-slate-500">{fact.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/components/owliver/OwliverEmptyState.jsx
Normal file
92
src/components/owliver/OwliverEmptyState.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import OwliverSuggestions from "./OwliverSuggestions";
|
||||
|
||||
/**
|
||||
* The pack marks the figure inside a read line with `**…**`. Splitting on it is
|
||||
* cheaper — and safer — than reaching for a markdown renderer for one emphasis.
|
||||
*/
|
||||
function Emphasised({ text }) {
|
||||
return text.split(/\*\*(.+?)\*\*/g).map((part, i) =>
|
||||
i % 2 === 1 ? (
|
||||
<span key={i} className="font-semibold text-slate-900">{part}</span>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel before anything has been asked.
|
||||
*
|
||||
* Not a greeting and not a dashboard: the read on the current workspace — which
|
||||
* the page's own data produced — followed by the questions that read makes worth
|
||||
* asking. Two short blocks and nothing else, because the conversation is the
|
||||
* surface and this is only its starting line.
|
||||
*/
|
||||
export default function OwliverEmptyState({
|
||||
read, loading, failed, suggestions, onSelect, disabled, workspaceTitle,
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2 px-0.5 pt-1" aria-live="polite">
|
||||
<div className="h-3 w-[45%] animate-pulse rounded-full bg-slate-200" />
|
||||
<div className="h-3.5 w-[85%] animate-pulse rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-[95%] animate-pulse rounded-full bg-slate-100" />
|
||||
<span className="sr-only">Owliver is reading this page’s data</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toneRail = {
|
||||
positive: "border-emerald-300",
|
||||
risk: "border-orange-300",
|
||||
neutral: "border-[#0039A6]/40",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{failed ? (
|
||||
<p className="text-[13px] leading-relaxed text-slate-600">
|
||||
I couldn’t read this page’s data just now — ask me anything and I’ll pull what I need.
|
||||
</p>
|
||||
) : (
|
||||
read && (
|
||||
<section className={`border-l-2 pl-3 ${toneRail[read.tone] || toneRail.neutral}`}>
|
||||
<p className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-slate-400">
|
||||
Current read
|
||||
</p>
|
||||
|
||||
<p className="mt-1.5 text-[14px] font-semibold leading-snug text-slate-900">
|
||||
{read.headline}
|
||||
</p>
|
||||
|
||||
{(read.lines || []).map((line, i) => (
|
||||
<p key={i} className="mt-1.5 text-[12.5px] leading-relaxed text-slate-500">
|
||||
<Emphasised text={line} />
|
||||
</p>
|
||||
))}
|
||||
|
||||
{read.stat && (
|
||||
<p className="mt-2.5 flex items-baseline gap-1.5 border-t border-slate-100 pt-2">
|
||||
<span className="text-[10.5px] font-medium uppercase tracking-wide text-slate-400">
|
||||
{read.stat.label}
|
||||
</span>
|
||||
<span className="text-[13px] font-bold tabular-nums text-slate-900">{read.stat.value}</span>
|
||||
{read.stat.trend && (
|
||||
<span className="text-[11.5px] font-semibold text-emerald-700">{read.stat.trend}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
)}
|
||||
|
||||
<OwliverSuggestions
|
||||
heading={`Explore ${workspaceTitle}`}
|
||||
suggestions={suggestions}
|
||||
onSelect={onSelect}
|
||||
disabled={disabled}
|
||||
limit={5}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +1,75 @@
|
||||
import { ArrowLeft, Maximize2, Minimize2, PanelRightClose, SquarePen } from "lucide-react";
|
||||
import OwliverAvatar from "./OwliverAvatar";
|
||||
import { Maximize2, Minimize2, MoreHorizontal, SquarePen, X } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
/**
|
||||
* Image 1 Header Style:
|
||||
* [ owl ] Owliver ← ✎ ↗ 🚪
|
||||
* Positions
|
||||
* Identity, and the page Owliver is bound to. Nothing else.
|
||||
*
|
||||
* Every control here does what its label says. The back arrow returns to the
|
||||
* prompt surface and only appears when a thread exists to leave; the pencil
|
||||
* starts a fresh conversation. (It previously rendered a History icon wired to
|
||||
* `onNewConversation`, which silently discarded the thread, and the back
|
||||
* control was never rendered at all — so a reply was a dead end.)
|
||||
* ✦ Owliver ⋯ ×
|
||||
* Saving Engine
|
||||
*
|
||||
* Two lines carry the only two facts a header needs here: who this is, and what
|
||||
* it is currently reading. Every secondary control collapses into one overflow
|
||||
* menu, so the header keeps a single visual weight and the panel's attention
|
||||
* stays on the conversation below it.
|
||||
*/
|
||||
export default function OwliverHeader({
|
||||
subtitle,
|
||||
onNewConversation,
|
||||
onShowInsight,
|
||||
onToggleExpanded,
|
||||
workspace,
|
||||
onClose,
|
||||
onNewConversation,
|
||||
onToggleExpanded,
|
||||
isExpanded,
|
||||
canGoBack,
|
||||
hasThread,
|
||||
}) {
|
||||
const iconBtn =
|
||||
"w-8 h-8 rounded-full flex items-center justify-center transition-colors " +
|
||||
"hover:bg-black/[0.05] focus-visible:outline-none focus-visible:ring-2 " +
|
||||
"disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
"flex h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors duration-150 " +
|
||||
"hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/30";
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex items-center gap-3 flex-shrink-0 px-4 pt-3 pb-2.5"
|
||||
style={{ borderColor: "var(--ow-line)" }}
|
||||
>
|
||||
<OwliverAvatar size={32} rounded="rounded-full" />
|
||||
<header className="flex flex-shrink-0 items-center gap-2.5 border-b border-slate-200 bg-white px-3.5 py-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md bg-[#0039A6] text-[13px] font-semibold leading-none text-white"
|
||||
>
|
||||
✦
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="text-[15px] font-bold leading-tight truncate"
|
||||
style={{ color: "var(--ow-ink)" }}
|
||||
>
|
||||
<p className="truncate text-[13.5px] font-bold leading-tight tracking-[-0.01em] text-slate-900">
|
||||
Owliver
|
||||
</p>
|
||||
<p
|
||||
className="text-[12.5px] font-medium leading-tight truncate"
|
||||
style={{ color: "var(--ow-ink-muted)" }}
|
||||
>
|
||||
{subtitle}
|
||||
<p className="truncate text-[11.5px] font-medium leading-tight text-slate-500">
|
||||
{workspace}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0" style={{ color: "var(--ow-ink-muted)" }}>
|
||||
{canGoBack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowInsight}
|
||||
aria-label="Back to suggestions"
|
||||
title="Back"
|
||||
className={iconBtn}
|
||||
style={{ outlineColor: "var(--ow-brand)" }}
|
||||
>
|
||||
<ArrowLeft className="w-[17px] h-[17px]" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-shrink-0 items-center gap-0.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" aria-label="Owliver options" title="Options" className={iconBtn}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem onClick={onNewConversation} disabled={!hasThread}>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
New conversation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onToggleExpanded} className="hidden lg:flex">
|
||||
{isExpanded ? <Minimize2 className="mr-2 h-4 w-4" /> : <Maximize2 className="mr-2 h-4 w-4" />}
|
||||
{isExpanded ? "Collapse panel" : "Expand panel"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{hasThread && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewConversation}
|
||||
aria-label="Start a new conversation"
|
||||
title="New conversation"
|
||||
className={iconBtn}
|
||||
style={{ outlineColor: "var(--ow-brand)" }}
|
||||
>
|
||||
<SquarePen className="w-[17px] h-[17px]" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpanded}
|
||||
aria-label={isExpanded ? "Collapse Owliver" : "Expand Owliver"}
|
||||
aria-pressed={isExpanded}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
className={`${iconBtn} hidden lg:flex`}
|
||||
style={{ outlineColor: "var(--ow-brand)" }}
|
||||
>
|
||||
{isExpanded
|
||||
? <Minimize2 className="w-[17px] h-[17px]" />
|
||||
: <Maximize2 className="w-[17px] h-[17px]" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close Owliver"
|
||||
title="Close"
|
||||
className={iconBtn}
|
||||
style={{ outlineColor: "var(--ow-brand)" }}
|
||||
>
|
||||
<PanelRightClose className="w-[18px] h-[18px]" />
|
||||
<button type="button" onClick={onClose} aria-label="Close Owliver" title="Close" className={iconBtn}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* The read on the current page, shown above the suggestions:
|
||||
*
|
||||
* 2 shifts are short of requested headcount this week.
|
||||
* Reviewing this week's schedule — coverage, labor cost and
|
||||
* upcoming staffing needs.
|
||||
* ─────────────────────────────────────────────────────
|
||||
*
|
||||
* The headline is the finding; the supporting line says what Owliver is reading
|
||||
* to get there, which is what makes the panel feel page-aware before you ask it
|
||||
* anything. Both come from the page's own data via buildInsight() — nothing here
|
||||
* is ever invented, and there is no greeting: the old "Good afternoon, Alex"
|
||||
* block cost ~120px before the user reached anything actionable, and its
|
||||
* placeholder copy was about hiring, on pages that have nothing to do with it.
|
||||
*
|
||||
* No insight is a valid state: the panel then opens straight onto its
|
||||
* suggestions.
|
||||
*/
|
||||
export default function OwliverInsight({ insight, loading, failed }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
<div className="h-4 w-[80%] rounded-full animate-pulse bg-neutral-200" />
|
||||
<div className="h-3 w-[95%] rounded-full animate-pulse bg-neutral-200" />
|
||||
<span className="sr-only">Owliver is reading this page’s data</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const line = failed
|
||||
? "I couldn't read this page's data just now — ask me anything and I'll pull what I need."
|
||||
: insight?.headline;
|
||||
const supporting = failed ? null : insight?.supporting;
|
||||
|
||||
if (!line) return null;
|
||||
|
||||
// No padding and no trailing rule: this sits inside an Astryx ChatMessage,
|
||||
// which owns the gutter and the separation from the next turn.
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className="text-[13.5px] font-medium leading-snug m-0"
|
||||
style={{ color: "var(--ow-ink)" }}
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
|
||||
{supporting && (
|
||||
<p
|
||||
className="text-[12.5px] leading-relaxed mt-1.5 m-0"
|
||||
style={{ color: "var(--ow-ink-muted)" }}
|
||||
>
|
||||
{supporting}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,29 +5,34 @@ import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { krow } from "@/api/client";
|
||||
import {
|
||||
resolveDomain, pageNameFromPath, detailEntityFor, rankSuggestions,
|
||||
resolveDomain, pageNameFromPath, detailEntityFor,
|
||||
OWLIVER_EXCLUDED, PAGE_CONTEXTS,
|
||||
} from "@/lib/owliver/pageRegistry";
|
||||
import { buildPageContext } from "@/lib/owliver/context";
|
||||
import getKnowledge from "@/lib/owliver/knowledge";
|
||||
import { getOwliverAnswer, lastTopicOf } from "@/lib/owliver/responseEngine";
|
||||
|
||||
/**
|
||||
* OwliverProvider
|
||||
* ---------------
|
||||
* Owns the Owliver conversation and the current page context. Mounted once at
|
||||
* the authenticated layout level, so the assistant survives navigation.
|
||||
* Owns the conversation and the page context.
|
||||
*
|
||||
* Performance contract: mounting this provider performs **no** entity reads.
|
||||
* Page signals load lazily the first time the user opens Owliver, and are then
|
||||
* cached per page by TanStack Query.
|
||||
* Active page → domain → knowledge pack → read / suggestions / answers
|
||||
*
|
||||
* There is exactly **one** submission path — `send()`. The prompt box and the
|
||||
* suggestion chips both call it; a suggestion is a pre-written prompt, never a
|
||||
* separate request shape and never a canned answer.
|
||||
* The page is the source of context. The pack resolved for the active page
|
||||
* decides what Owliver knows, what it offers to answer, and how it answers —
|
||||
* so nothing on this surface is shared between pages, and a page that has no
|
||||
* pack still falls back to its own registry entry rather than to a generic one.
|
||||
*
|
||||
* Answers come from the on-device engine in lib/owliver/owliverEngine.js, which
|
||||
* reasons over `pageContext` — the same normalised page data the panel already
|
||||
* has. Nothing is sent anywhere: there is no conversation on a server, no socket
|
||||
* and no poll, so a turn is a local async call that always settles.
|
||||
* There is exactly one submission path — `send()`. Suggestion chips, follow-up
|
||||
* chips, in-answer action buttons and the composer all call it; a suggestion is
|
||||
* a pre-written prompt, never a canned answer.
|
||||
*
|
||||
* A turn is staged rather than streamed: the answer is composed locally, then
|
||||
* its thinking lines are played out and its blocks revealed one at a time. That
|
||||
* is deliberate — when a real streaming transport replaces the local pack, the
|
||||
* same three states (thinking → partial → complete) are already what the
|
||||
* surface renders.
|
||||
*/
|
||||
|
||||
const OwliverContext = createContext(null);
|
||||
@@ -38,45 +43,44 @@ export const useOwliver = () => {
|
||||
return ctx;
|
||||
};
|
||||
|
||||
/** Pacing for the staged turn. Long enough to read, short enough not to wait. */
|
||||
const STAGE_MS = 620;
|
||||
const BLOCK_MS = 260;
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export function OwliverProvider({ children }) {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Owliver opens with the page. It is a non-modal card beside the work, not a
|
||||
// dialog the user has to go and find, so there is no closed default state to
|
||||
// discover — `close()` and the launcher pill handle dismiss/reopen.
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
// Docked beside the work on a desktop, a bottom sheet on a phone — where it
|
||||
// would bury the page it is about, so it starts closed there.
|
||||
const [isOpen, setIsOpen] = useState(
|
||||
() => typeof window === "undefined" || window.matchMedia("(min-width: 768px)").matches
|
||||
);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// Owliver is prompt-first: which surface is showing is explicit state, never
|
||||
// inferred from `messages.length`. "prompt" is greeting → insight →
|
||||
// suggestions → input; only a deliberate user action moves to
|
||||
// "conversation".
|
||||
const [viewMode, setViewMode] = useState("prompt");
|
||||
// The panel is open from the first paint, so its page signals are wanted from
|
||||
// the first paint too.
|
||||
const [hasOpened, setHasOpened] = useState(true);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// `isSending` means "a turn is in flight", from the moment the user submits
|
||||
// until the agent has finished answering. The response surface decides on its
|
||||
// own when to stop showing the working line (as soon as real text lands), so
|
||||
// this can stay true through streaming without pinning a spinner on screen.
|
||||
const [isSending, setIsSendingState] = useState(false);
|
||||
const [thinkingStage, setThinkingStage] = useState(null);
|
||||
const [followUps, setFollowUps] = useState([]);
|
||||
|
||||
const isSendingRef = useRef(false);
|
||||
const setSending = useCallback((next) => {
|
||||
isSendingRef.current = next;
|
||||
setIsSendingState(next);
|
||||
}, []);
|
||||
|
||||
// Every turn takes a ticket. A turn whose ticket has been superseded — by a
|
||||
// navigation, a reset, or a newer question — stops writing to state instead of
|
||||
// finishing into a conversation that has moved on.
|
||||
const turnRef = useRef(0);
|
||||
const lastSentRef = useRef(null);
|
||||
// Mirrors `messages` so failure handling can read the current thread without
|
||||
// taking a dependency on it (and re-arming the timeout on every token).
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
// A page may publish its own live data (including tab/view state and the
|
||||
// selected record) via useOwliverPageContext.
|
||||
/* ------------------------------------------------------- page context */
|
||||
|
||||
const [pageRegistration, setPageRegistration] = useState({ key: null, data: null });
|
||||
const registerPage = useCallback((key, data) => {
|
||||
setPageRegistration((prev) =>
|
||||
@@ -88,13 +92,13 @@ export function OwliverProvider({ children }) {
|
||||
const isExcluded = OWLIVER_EXCLUDED.has(pageName);
|
||||
const detailEntity = detailEntityFor(pageName);
|
||||
|
||||
// A registered page context wins over the route-derived domain — it knows
|
||||
// which in-page view is active.
|
||||
// A page that registers its own context wins over the route-derived domain —
|
||||
// it knows which in-page view is active.
|
||||
const registered = pageRegistration.key ? PAGE_CONTEXTS[pageRegistration.key] : null;
|
||||
const domain = useMemo(
|
||||
() => registered || resolveDomain(pageName),
|
||||
[registered, pageName]
|
||||
);
|
||||
const domain = useMemo(() => registered || resolveDomain(pageName), [registered, pageName]);
|
||||
|
||||
/** The knowledge pack for this page. Everything below reads from it. */
|
||||
const knowledge = useMemo(() => getKnowledge(domain.key, domain), [domain]);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ["current-user-layout"], // shared with Layout — no extra request
|
||||
@@ -106,9 +110,9 @@ export function OwliverProvider({ children }) {
|
||||
(typeof localStorage !== "undefined" && localStorage.getItem("dev_role_override")) ||
|
||||
user?.user_role || user?.role || "admin";
|
||||
|
||||
/* ---------------------------------------------------------- page signals */
|
||||
// Owliver prefers data the page already loaded. It only fetches its own —
|
||||
// and only after the user has opened it once — when no page has registered.
|
||||
/* ---------------------------------------------------------- page data */
|
||||
// Owliver prefers data the page already loaded, and only fetches its own when
|
||||
// no page has registered.
|
||||
const usePageData = !!registered;
|
||||
|
||||
const {
|
||||
@@ -118,13 +122,12 @@ export function OwliverProvider({ children }) {
|
||||
} = useQuery({
|
||||
queryKey: ["owliver-signals", domain.key],
|
||||
queryFn: () => domain.loadSignals(),
|
||||
enabled: hasOpened && !isExcluded && !usePageData && !!domain.loadSignals,
|
||||
enabled: !isExcluded && !usePageData && !!domain.loadSignals,
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
// `signals` is the normalised summary the registry reasons about.
|
||||
const signals = useMemo(() => {
|
||||
// `|| {}` matters: destructuring defaults cover `undefined` but throw on `null`.
|
||||
if (usePageData) return domain.summarise(pageRegistration.data || {});
|
||||
@@ -134,104 +137,103 @@ export function OwliverProvider({ children }) {
|
||||
const signalsLoading = usePageData ? false : fetchLoading;
|
||||
const signalsError = usePageData ? false : fetchError;
|
||||
|
||||
const insight = useMemo(
|
||||
() => (signals ? domain.buildInsight(signals) : null),
|
||||
[signals, domain]
|
||||
/** The dynamic current read — a different shape of finding on every page. */
|
||||
const read = useMemo(
|
||||
() => (signals ? knowledge.read(signals) : null),
|
||||
[signals, knowledge]
|
||||
);
|
||||
|
||||
/** The questions this page's data makes worth asking. */
|
||||
const suggestions = useMemo(
|
||||
() => (signals ? rankSuggestions(domain.buildSuggestions(signals)) : []),
|
||||
[signals, domain]
|
||||
() => (signals ? (knowledge.suggestions(signals) || []).slice(0, 5) : []),
|
||||
[signals, knowledge]
|
||||
);
|
||||
|
||||
const pageContext = useMemo(
|
||||
() => buildPageContext({
|
||||
pageName, domain, role, searchParams, signals, insight, detailEntity,
|
||||
pageName, domain, role, searchParams, signals,
|
||||
insight: read ? { headline: read.headline, supporting: read.lines?.[0] } : null,
|
||||
detailEntity,
|
||||
}),
|
||||
[pageName, domain, role, searchParams, signals, insight, detailEntity]
|
||||
[pageName, domain, role, searchParams, signals, read, detailEntity]
|
||||
);
|
||||
|
||||
// A request that never lands must not leave the surface looking broken.
|
||||
// Roll the optimistic turn back off the thread, return to the prompt-first
|
||||
// surface, and let the compact inline error carry the message — the insight
|
||||
// and suggestions stay available underneath it.
|
||||
const failSend = useCallback((err) => {
|
||||
if (err) console.error("Owliver: request failed", err);
|
||||
setSending(false);
|
||||
/* ------------------------------------------------------- conversation */
|
||||
|
||||
const prev = messagesRef.current;
|
||||
const last = prev[prev.length - 1];
|
||||
// Drop the optimistic turn we echoed; it was never persisted.
|
||||
const rolledBack = last && last.__optimistic ? prev.slice(0, -1) : prev;
|
||||
setMessages(rolledBack);
|
||||
|
||||
// Only fall back to the prompt surface when nothing was actually answered.
|
||||
// A failed follow-up must not yank the user away from the reply they are
|
||||
// still reading — the compact error sits above the prompt either way.
|
||||
if (!rolledBack.some((m) => m.role !== "user")) setViewMode("prompt");
|
||||
|
||||
// Carry the status through when the API gave one. A bare "couldn't complete
|
||||
// that request" is indistinguishable between a missing agent, an expired
|
||||
// session and a server fault, which is exactly what made this hard to place.
|
||||
const status = err?.status ? ` (${err.status})` : "";
|
||||
setError(`Owliver couldn't complete that request.${status}`);
|
||||
}, [setSending]);
|
||||
|
||||
// A new page is a new context. Return to the prompt surface, drop the
|
||||
// visible thread and invalidate the context we last sent, so Owliver always
|
||||
// opens on what *this* page needs rather than on an answer about the last
|
||||
// one. (The route is the only thing that resets this — navigating is a
|
||||
// deliberate act, so it can't surprise the user mid-reply.)
|
||||
//
|
||||
// Keyed on the raw search *string*, not the URLSearchParams object: an
|
||||
// identity change there would tear down a healthy in-flight turn.
|
||||
// A new page is a new context: drop the thread, cancel any turn in flight and
|
||||
// open on what *this* page needs. Keyed on the raw search string, not the
|
||||
// URLSearchParams object, whose identity changes on every render.
|
||||
useEffect(() => {
|
||||
turnRef.current += 1;
|
||||
setSending(false);
|
||||
setViewMode("prompt");
|
||||
setThinkingStage(null);
|
||||
setMessages([]);
|
||||
setFollowUps([]);
|
||||
setError(null);
|
||||
}, [pageName, location.search, setSending]);
|
||||
|
||||
/**
|
||||
* The one submission path. `rawText` is whatever the user asked — typed into
|
||||
* the prompt box or carried by a suggestion chip.
|
||||
* The one submission path. `rawText` is whatever the user asked — typed, or
|
||||
* carried by a suggestion, a follow-up or an in-answer action.
|
||||
*/
|
||||
const send = useCallback(async (rawText) => {
|
||||
const text = (rawText || "").trim();
|
||||
if (!text || isSendingRef.current) return;
|
||||
|
||||
const ticket = (turnRef.current += 1);
|
||||
const live = () => turnRef.current === ticket;
|
||||
|
||||
lastSentRef.current = text;
|
||||
setError(null);
|
||||
setFollowUps([]);
|
||||
setSending(true);
|
||||
|
||||
// An actual request — and only an actual request — moves Owliver into the
|
||||
// response surface. Echo the user's turn first so a click on a suggestion
|
||||
// produces immediate visible feedback; `failSend` rolls it back if the
|
||||
// answer never arrives.
|
||||
setViewMode("conversation");
|
||||
// The thread as it stood *before* this turn — what a follow-up ("why?") is
|
||||
// answered against.
|
||||
const priorMessages = messagesRef.current;
|
||||
setMessages((prev) => [...prev, { role: "user", content: text, __optimistic: true }]);
|
||||
// The user's turn lands immediately — a click on a suggestion has to produce
|
||||
// visible feedback before anything is computed.
|
||||
const prior = messagesRef.current;
|
||||
setMessages([...prior, { id: `you-${Date.now()}`, role: "user", content: text }]);
|
||||
setThinkingStage(`${knowledge.working}…`);
|
||||
|
||||
try {
|
||||
// Loaded on demand so the engine only ships to sessions that open Owliver.
|
||||
const { getOwliverResponse } = await import("@/lib/owliver/owliverEngine");
|
||||
const reply = await getOwliverResponse({
|
||||
prompt: text,
|
||||
pageContext,
|
||||
conversation: priorMessages,
|
||||
});
|
||||
setMessages((prev) => [
|
||||
// Nothing will echo this turn back, so it stops being provisional.
|
||||
...prev.map((m) => (m.__optimistic ? { ...m, __optimistic: false } : m)),
|
||||
{ id: `owliver-${Date.now()}`, role: "assistant", content: reply },
|
||||
]);
|
||||
setSending(false);
|
||||
} catch (err) {
|
||||
failSend(err);
|
||||
const answer = await getOwliverAnswer({
|
||||
prompt: text,
|
||||
domainKey: domain.key,
|
||||
domain,
|
||||
signals,
|
||||
pageContext,
|
||||
conversation: prior,
|
||||
});
|
||||
if (!live()) return;
|
||||
|
||||
// Play the answer's own working lines ("Checking 27 opportunities…"), then
|
||||
// hand over to the response surface.
|
||||
const stages = answer.stages?.length ? answer.stages : [`${knowledge.working}…`];
|
||||
for (const stage of stages) {
|
||||
setThinkingStage(stage);
|
||||
await wait(STAGE_MS);
|
||||
if (!live()) return;
|
||||
}
|
||||
}, [pageContext, failSend, setSending]);
|
||||
|
||||
setThinkingStage(null);
|
||||
const id = `owliver-${Date.now()}`;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id, role: "assistant", blocks: answer.blocks, topic: answer.topic, revealed: 1 },
|
||||
]);
|
||||
|
||||
// Blocks land one at a time: the text of the answer, then the evidence it
|
||||
// rests on, then the actions. The composer stays disabled until the answer
|
||||
// is whole, so a follow-up is never asked against half an answer.
|
||||
for (let i = 2; i <= answer.blocks.length; i += 1) {
|
||||
await wait(BLOCK_MS);
|
||||
if (!live()) return;
|
||||
setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, revealed: i } : m)));
|
||||
}
|
||||
|
||||
if (!live()) return;
|
||||
setFollowUps(answer.followUps || []);
|
||||
if (answer.error) setError("Something went wrong while analyzing this workspace.");
|
||||
setSending(false);
|
||||
}, [domain, knowledge, signals, pageContext, setSending]);
|
||||
|
||||
/** Re-run the prompt that failed, not a blank one. */
|
||||
const retry = useCallback(() => {
|
||||
@@ -241,47 +243,59 @@ export function OwliverProvider({ children }) {
|
||||
send(text);
|
||||
}, [send]);
|
||||
|
||||
// Opening always lands on the prompt-first surface, whatever happened in an
|
||||
// earlier turn.
|
||||
const open = useCallback(() => {
|
||||
setHasOpened(true);
|
||||
setIsOpen(true);
|
||||
setViewMode("prompt");
|
||||
setError(null);
|
||||
}, []);
|
||||
const open = useCallback(() => { setIsOpen(true); setError(null); }, []);
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
const toggle = useCallback(() => (isOpen ? close() : open()), [isOpen, open, close]);
|
||||
const toggleExpanded = useCallback(() => setIsExpanded((v) => !v), []);
|
||||
|
||||
const newConversation = useCallback(() => {
|
||||
turnRef.current += 1;
|
||||
setSending(false);
|
||||
setThinkingStage(null);
|
||||
setMessages([]);
|
||||
setFollowUps([]);
|
||||
setError(null);
|
||||
setViewMode("prompt");
|
||||
}, [setSending]);
|
||||
|
||||
// Return to the prompt surface without discarding the thread.
|
||||
const showInsight = useCallback(() => { setViewMode("prompt"); setError(null); }, []);
|
||||
const hasThread = messages.length > 0;
|
||||
|
||||
// An answer normally names its own follow-ups. When it doesn't — the pages
|
||||
// answered through the fallback adapter can't — the page's own remaining
|
||||
// suggestions stand in, minus anything already asked in this thread. Offering
|
||||
// back a question the user just asked is the fastest way to make a panel feel
|
||||
// like it isn't listening.
|
||||
const effectiveFollowUps = useMemo(() => {
|
||||
if (followUps.length) return followUps;
|
||||
const asked = new Set(
|
||||
messages.filter((m) => m.role === "user").map((m) => (m.content || "").trim())
|
||||
);
|
||||
return suggestions
|
||||
.filter((s) => !asked.has((s.prompt || s.label || "").trim()))
|
||||
.slice(0, 3);
|
||||
}, [followUps, messages, suggestions]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
isOpen, open, close, toggle,
|
||||
isExpanded, toggleExpanded,
|
||||
isAvailable: !isExcluded,
|
||||
messages, send, retry, isSending, error,
|
||||
newConversation, showInsight, registerPage,
|
||||
atInsight: viewMode === "prompt",
|
||||
// Whether there is a thread to go back to — drives the header's back
|
||||
// control, so it only appears when it actually does something.
|
||||
hasThread: messages.length > 0,
|
||||
domain, pageName, pageContext, role,
|
||||
signals, signalsLoading, signalsError,
|
||||
insight, suggestions,
|
||||
|
||||
// conversation
|
||||
messages, send, retry, isSending, error, thinkingStage,
|
||||
followUps: effectiveFollowUps,
|
||||
newConversation, hasThread,
|
||||
lastTopic: lastTopicOf(messages),
|
||||
|
||||
// page context
|
||||
registerPage, domain, knowledge, pageName, pageContext, role,
|
||||
signals, signalsLoading, signalsError, read, suggestions,
|
||||
placeholder: knowledge.placeholder,
|
||||
contextLabel: knowledge.contextLabel,
|
||||
workspaceTitle: knowledge.title,
|
||||
}), [
|
||||
isOpen, open, close, toggle, isExpanded, toggleExpanded, isExcluded,
|
||||
messages, send, retry, isSending, error, newConversation, showInsight,
|
||||
viewMode,
|
||||
registerPage, domain, pageName, pageContext, role, signals,
|
||||
signalsLoading, signalsError, insight, suggestions, user,
|
||||
messages, send, retry, isSending, error, thinkingStage, effectiveFollowUps,
|
||||
newConversation, hasThread, registerPage, domain, knowledge, pageName,
|
||||
pageContext, role, signals, signalsLoading, signalsError, read, suggestions,
|
||||
]);
|
||||
|
||||
return <OwliverContext.Provider value={value}>{children}</OwliverContext.Provider>;
|
||||
|
||||
@@ -1,98 +1,97 @@
|
||||
import { useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
ChatLayout,
|
||||
ChatMessageList,
|
||||
ChatMessage,
|
||||
ChatComposer,
|
||||
} from "@astryxdesign/core";
|
||||
import { useOwliver } from "./OwliverProvider";
|
||||
import OwliverHeader from "./OwliverHeader";
|
||||
import OwliverInsight from "./OwliverInsight";
|
||||
import OwliverContextBar from "./OwliverContextBar";
|
||||
import OwliverEmptyState from "./OwliverEmptyState";
|
||||
import OwliverResponse from "./blocks/OwliverResponse";
|
||||
import OwliverSuggestions from "./OwliverSuggestions";
|
||||
import OwliverAnswer from "./OwliverAnswer";
|
||||
import OwliverAvatar from "./OwliverAvatar";
|
||||
import OwliverComposer from "./OwliverComposer";
|
||||
import OwliverThinking from "./OwliverThinking";
|
||||
import OwliverLauncher from "./OwliverLauncher";
|
||||
|
||||
/** Owliver's own mark on every turn he speaks — Astryx slots it, KROW supplies it. */
|
||||
const owliverMark = <OwliverAvatar size={20} rounded="rounded-md" />;
|
||||
|
||||
/**
|
||||
* OwliverShell
|
||||
* ------------
|
||||
* Owliver's chat surface, built on Astryx's ChatLayout (@astryxdesign/core).
|
||||
* OwliverShell — the AI workspace.
|
||||
*
|
||||
* Astryx supplies the structure and the chat behaviour; KROW supplies the
|
||||
* branding and every piece of content. Nothing about the provider, the local
|
||||
* engine, the page registry or the demo data changes — this file only decides
|
||||
* how a turn is presented.
|
||||
* ┌──────────────────────────────────────┐
|
||||
* │ ✦ Owliver ⋯ × │ fixed — identity + page
|
||||
* │ Saving Engine │
|
||||
* │ ⛁ Using Saving Engine data ⌄ │ fixed — what is being read
|
||||
* ├──────────────────────────────────────┤
|
||||
* │ │
|
||||
* │ conversation — text, metrics, cards, │ the only band that scrolls
|
||||
* │ tables, charts, flows, actions │
|
||||
* │ │
|
||||
* ├──────────────────────────────────────┤
|
||||
* │ Suggested follow-ups │ fixed — derived from the answer
|
||||
* │ ( … ) ( … ) │
|
||||
* │ ┌──────────────────────────────────┐ │
|
||||
* │ │ Ask Owliver about Saving Engine… │ │ fixed
|
||||
* │ │ ✦ Saving Engine [ ↑ ] │ │
|
||||
* │ └──────────────────────────────────┘ │
|
||||
* └──────────────────────────────────────┘
|
||||
*
|
||||
* Why ChatLayout does the work here:
|
||||
* • With no `scrollRef`, its own root is the scroll container and the composer
|
||||
* rides a sticky dock at the bottom. That is exactly the required contract —
|
||||
* header pinned, conversation scrolling, composer always reachable — with no
|
||||
* scroll plumbing of our own.
|
||||
* • `ChatMessage` renders a flat sender-attributed row, not a bubble, which is
|
||||
* the message hierarchy this needs.
|
||||
* • `ChatComposer` already owns the input, the send button, the disabled state
|
||||
* and an error status line, so `OwliverPrompt` and `OwliverError` are gone
|
||||
* rather than reimplemented beside it.
|
||||
* The conversation is the surface. Everything else is a thin band around it:
|
||||
* the panel owns its own scrollport, so a long answer never lengthens the page
|
||||
* behind it, and the composer is always one keystroke away from wherever the
|
||||
* user has read to.
|
||||
*
|
||||
* `density="compact"` because this is a ~360px rail rather than a full-page
|
||||
* chat; the default 'balanced' is tuned for a centred conversation column.
|
||||
*
|
||||
* The default state is not a special screen. It is the same ChatMessageList
|
||||
* holding one assistant turn — the page read — followed by the same suggestion
|
||||
* chips the reply state uses, so there is only ever one visual system.
|
||||
*
|
||||
* Placement (right-hand rail on desktop, bottom sheet on small screens) stays in
|
||||
* `.krow-owliver-panel`; Astryx owns everything inside it.
|
||||
* Two scrolling rules, both about not fighting the reader. New turns are
|
||||
* followed automatically, but only while the user is already at the bottom —
|
||||
* scroll up to re-read an earlier answer and the panel leaves you there.
|
||||
*/
|
||||
export default function OwliverShell() {
|
||||
const {
|
||||
isOpen, close, isAvailable, isExpanded, toggleExpanded,
|
||||
domain, insight, suggestions, signalsLoading, signalsError,
|
||||
atInsight, showInsight, hasThread, newConversation,
|
||||
messages, send, isSending, error, retry,
|
||||
knowledge, workspaceTitle, contextLabel, placeholder,
|
||||
read, suggestions, signals, signalsLoading, signalsError,
|
||||
hasThread, newConversation,
|
||||
messages, send, isSending, error, retry, thinkingStage, followUps,
|
||||
} = useOwliver();
|
||||
|
||||
const launcherRef = useRef(null);
|
||||
const scrollRef = useRef(null);
|
||||
const endRef = useRef(null);
|
||||
const [following, setFollowing] = useState(true);
|
||||
|
||||
// "Following" means the user is reading the newest turn. Anything more than a
|
||||
// short distance from the bottom means they have gone back for something, and
|
||||
// the panel stops pulling them forward until they return.
|
||||
const onScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setFollowing(distance < 80);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !following) return;
|
||||
endRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
|
||||
}, [messages, thinkingStage, isSending, isOpen, following]);
|
||||
|
||||
// A new question is always followed, wherever the user had scrolled to.
|
||||
const submit = useCallback((text) => {
|
||||
setFollowing(true);
|
||||
send(text);
|
||||
}, [send]);
|
||||
|
||||
// The other floating controls step aside while the panel is open (the rule
|
||||
// lives in index.css; this is what turns it on).
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isOpen && isAvailable) root.setAttribute("data-owliver-open", "true");
|
||||
else root.removeAttribute("data-owliver-open");
|
||||
return () => root.removeAttribute("data-owliver-open");
|
||||
}, [isOpen, isAvailable]);
|
||||
|
||||
if (!isAvailable) return null;
|
||||
|
||||
const placeholder =
|
||||
domain.placeholder || `Ask Owliver about ${domain.title.toLowerCase()}...`;
|
||||
|
||||
// Follow-ups are the same page-derived suggestions minus anything already
|
||||
// asked in this thread — offering back a question the user just asked is the
|
||||
// fastest way to make a panel feel like it isn't listening.
|
||||
const asked = new Set(
|
||||
messages.filter((m) => m.role === "user").map((m) => (m.content || "").trim())
|
||||
);
|
||||
const followUps = suggestions
|
||||
.filter((s) => !asked.has((s.prompt || s.label || "").trim()))
|
||||
.slice(0, 3);
|
||||
|
||||
const composer = (
|
||||
<ChatComposer
|
||||
onSubmit={send}
|
||||
placeholder={atInsight ? placeholder : "Ask a follow-up..."}
|
||||
isDisabled={isSending}
|
||||
density="compact"
|
||||
elevation="none"
|
||||
// A failure belongs to the composer, not the transcript: it reports on the
|
||||
// control that failed and leaves the answer above it readable.
|
||||
status={error ? { type: "error", message: error } : undefined}
|
||||
/>
|
||||
);
|
||||
const facts = signals ? knowledge.contextFacts(signals) || [] : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dismissing the panel leaves the pill behind, so the page never loses
|
||||
its way back to Owliver. Rendered unconditionally — the launcher owns
|
||||
its own `!isOpen` guard inside an AnimatePresence, and gating it here
|
||||
too would unmount it before it could animate out. */}
|
||||
<OwliverLauncher ref={launcherRef} />
|
||||
its way back to Owliver. */}
|
||||
<OwliverLauncher />
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
@@ -100,93 +99,99 @@ export default function OwliverShell() {
|
||||
key="ow-panel"
|
||||
data-owliver=""
|
||||
role="complementary"
|
||||
aria-label={`Owliver — ${domain.title}`}
|
||||
aria-label={`Owliver — ${workspaceTitle}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
className="krow-owliver-panel z-40 flex flex-col overflow-hidden"
|
||||
data-expanded={isExpanded ? "true" : "false"}
|
||||
data-mode={atInsight ? "prompt" : "conversation"}
|
||||
>
|
||||
<OwliverHeader
|
||||
subtitle={domain.title}
|
||||
workspace={workspaceTitle}
|
||||
onClose={close}
|
||||
onNewConversation={newConversation}
|
||||
onShowInsight={showInsight}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
isExpanded={isExpanded}
|
||||
canGoBack={!atInsight}
|
||||
hasThread={hasThread}
|
||||
/>
|
||||
|
||||
{/* ChatLayout is the whole body: it scrolls internally and docks the
|
||||
composer. `scrollButton={null}` because a 360px rail with a
|
||||
pinned composer has nowhere to put a floating jump button. */}
|
||||
<div className="krow-owliver-chat min-h-0 flex-1">
|
||||
<ChatLayout
|
||||
composer={composer}
|
||||
density="compact"
|
||||
scrollButton={null}
|
||||
>
|
||||
<ChatMessageList density="compact" isStreaming={isSending}>
|
||||
{atInsight ? (
|
||||
<ChatMessage sender="assistant" name="Owliver" avatar={owliverMark}>
|
||||
<OwliverInsight
|
||||
insight={insight}
|
||||
loading={signalsLoading}
|
||||
failed={signalsError}
|
||||
/>
|
||||
<OwliverSuggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={send}
|
||||
disabled={isSending}
|
||||
/>
|
||||
</ChatMessage>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message, i) => (
|
||||
<ChatMessage
|
||||
key={message.id || i}
|
||||
sender={message.role === "user" ? "user" : "assistant"}
|
||||
name={message.role === "user" ? undefined : "Owliver"}
|
||||
avatar={message.role === "user" ? undefined : owliverMark}
|
||||
>
|
||||
<OwliverAnswer message={message} />
|
||||
</ChatMessage>
|
||||
))}
|
||||
<OwliverContextBar label={contextLabel} facts={facts} loading={signalsLoading} />
|
||||
|
||||
{/* Where an answer leaves you: the next useful question,
|
||||
in the same chips as the default state. Held back
|
||||
while a turn is in flight. */}
|
||||
{!isSending && hasThread && followUps.length > 0 && (
|
||||
<ChatMessage sender="assistant" name="Owliver" avatar={owliverMark}>
|
||||
<OwliverSuggestions
|
||||
heading="Follow up"
|
||||
suggestions={followUps}
|
||||
onSelect={send}
|
||||
disabled={isSending}
|
||||
/>
|
||||
</ChatMessage>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ChatMessage sender="system">
|
||||
<button
|
||||
type="button"
|
||||
onClick={retry}
|
||||
className="text-[12.5px] font-medium underline underline-offset-2"
|
||||
style={{ color: "var(--ow-brand)" }}
|
||||
>
|
||||
Try that again
|
||||
</button>
|
||||
</ChatMessage>
|
||||
)}
|
||||
</>
|
||||
{/* The one scrolling band. */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-3.5 py-3.5"
|
||||
>
|
||||
{!hasThread ? (
|
||||
<OwliverEmptyState
|
||||
read={read}
|
||||
loading={signalsLoading}
|
||||
failed={signalsError}
|
||||
suggestions={suggestions}
|
||||
onSelect={submit}
|
||||
disabled={isSending}
|
||||
workspaceTitle={workspaceTitle}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{messages.map((message, i) =>
|
||||
message.role === "user" ? (
|
||||
<div key={message.id || i} className="ow-fade-in">
|
||||
<p className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-slate-400">
|
||||
You
|
||||
</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-[13px] leading-[1.55] text-slate-800">
|
||||
{message.content}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div key={message.id || i}>
|
||||
<p className="mb-1.5 text-[9.5px] font-bold uppercase tracking-[0.12em] text-[#0039A6]">
|
||||
Owliver
|
||||
</p>
|
||||
<OwliverResponse
|
||||
blocks={(message.blocks || []).slice(0, message.revealed ?? message.blocks?.length)}
|
||||
onPrompt={submit}
|
||||
onRetry={retry}
|
||||
disabled={isSending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</ChatMessageList>
|
||||
</ChatLayout>
|
||||
|
||||
{isSending && thinkingStage && <OwliverThinking stage={thinkingStage} />}
|
||||
|
||||
<div ref={endRef} className="h-px" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Where an answer leaves you: the questions it made worth asking.
|
||||
Only after a turn — before one, the suggestions live in the
|
||||
conversation area where there is room to read them. */}
|
||||
{hasThread && !isSending && followUps.length > 0 && (
|
||||
<div className="flex-shrink-0 border-t border-slate-200 bg-slate-50/70 px-3.5 py-2">
|
||||
<OwliverSuggestions
|
||||
heading="Suggested follow-ups"
|
||||
variant="row"
|
||||
suggestions={followUps}
|
||||
onSelect={submit}
|
||||
disabled={isSending}
|
||||
limit={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OwliverComposer
|
||||
onSubmit={submit}
|
||||
placeholder={placeholder}
|
||||
contextLabel={workspaceTitle}
|
||||
disabled={isSending}
|
||||
error={error}
|
||||
onRetry={retry}
|
||||
/>
|
||||
</motion.section>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,107 +1,59 @@
|
||||
import {
|
||||
Sparkles,
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Users,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Returns a contextual icon matching the suggestion prompt text.
|
||||
* Questions this page's data makes worth asking.
|
||||
*
|
||||
* They are prompts, not shortcuts: clicking one goes through the same `send()`
|
||||
* path as typing it. The list is derived from the active page's knowledge pack,
|
||||
* so it changes with the workspace — and after an answer it is replaced by that
|
||||
* answer's own follow-ups, which is what stops the panel reading like a fixed
|
||||
* command menu.
|
||||
*/
|
||||
function getSuggestionIcon(label = "") {
|
||||
const text = String(label || "").toLowerCase();
|
||||
if (
|
||||
text.includes("short") ||
|
||||
text.includes("gap") ||
|
||||
text.includes("understaffed") ||
|
||||
text.includes("conflict") ||
|
||||
text.includes("risk") ||
|
||||
text.includes("emergency")
|
||||
) {
|
||||
return AlertTriangle;
|
||||
}
|
||||
if (
|
||||
text.includes("cost") ||
|
||||
text.includes("budget") ||
|
||||
text.includes("spend") ||
|
||||
text.includes("$") ||
|
||||
text.includes("payroll") ||
|
||||
text.includes("labor")
|
||||
) {
|
||||
return DollarSign;
|
||||
}
|
||||
if (
|
||||
text.includes("coverage") ||
|
||||
text.includes("upcoming") ||
|
||||
text.includes("week") ||
|
||||
text.includes("schedule") ||
|
||||
text.includes("shift")
|
||||
) {
|
||||
return Calendar;
|
||||
}
|
||||
if (
|
||||
text.includes("staff") ||
|
||||
text.includes("worker") ||
|
||||
text.includes("candidate") ||
|
||||
text.includes("applicant") ||
|
||||
text.includes("roster")
|
||||
) {
|
||||
return Users;
|
||||
}
|
||||
if (
|
||||
text.includes("summarize") ||
|
||||
text.includes("overview") ||
|
||||
text.includes("recap") ||
|
||||
text.includes("report") ||
|
||||
text.includes("health")
|
||||
) {
|
||||
return Sparkles;
|
||||
}
|
||||
return ChevronRight;
|
||||
}
|
||||
export default function OwliverSuggestions({
|
||||
suggestions,
|
||||
onSelect,
|
||||
disabled,
|
||||
heading = "Suggested questions",
|
||||
limit,
|
||||
variant = "wrap",
|
||||
}) {
|
||||
const items = (suggestions || [])
|
||||
.filter(Boolean)
|
||||
.map((item) => (typeof item === "string"
|
||||
? { label: item, prompt: item }
|
||||
: { label: item.label || item.prompt, prompt: item.prompt || item.label }))
|
||||
.filter((item) => item.label);
|
||||
|
||||
export default function OwliverSuggestions({ suggestions, onSelect, disabled, heading }) {
|
||||
if (!suggestions?.length) return null;
|
||||
|
||||
const sectionHeading = heading || "Suggested actions";
|
||||
const shown = limit ? items.slice(0, limit) : items;
|
||||
if (!shown.length) return null;
|
||||
|
||||
return (
|
||||
<nav aria-label="Suggested actions" className="mt-3.5 mb-2">
|
||||
<div className="flex items-center gap-1.5 mb-2.5">
|
||||
<Sparkles className="w-3.5 h-3.5 text-[#0039A6]" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
|
||||
{sectionHeading}
|
||||
</span>
|
||||
</div>
|
||||
<nav aria-label={heading} className="ow-fade-in">
|
||||
{heading && (
|
||||
<p className="mb-1.5 text-[9.5px] font-bold uppercase tracking-[0.12em] text-slate-400">
|
||||
{heading}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{suggestions.map((item, index) => {
|
||||
const labelText = typeof item === "string" ? item : item.label || item.prompt || "";
|
||||
const promptValue = typeof item === "string" ? item : item.prompt || item.label || "";
|
||||
if (!labelText) return null;
|
||||
|
||||
const Icon = getSuggestionIcon(labelText);
|
||||
const isPrimary = index === 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={labelText || index}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(promptValue)}
|
||||
className={`inline-flex items-center gap-2 px-3.5 py-2 rounded-xl text-[13px] font-semibold transition-all duration-150 cursor-pointer max-w-full text-left active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/40 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isPrimary
|
||||
? "bg-blue-50/90 text-[#0039A6] border border-[#0039A6]/35 hover:bg-blue-100/90 hover:border-[#0039A6]/60 shadow-2xs"
|
||||
: "bg-white text-slate-700 border border-slate-200/90 hover:border-[#0039A6]/40 hover:bg-blue-50/60 hover:text-[#0039A6] shadow-2xs"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-3.5 h-3.5 shrink-0 ${isPrimary ? "text-[#0039A6]" : "text-slate-400"}`} />
|
||||
<span className="truncate">{labelText}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Docked, the chips wrap into the room the panel has. On the phone sheet
|
||||
that room is vertical and scarce, so they become one scrolling row
|
||||
instead of eating three lines of the conversation. */}
|
||||
<div
|
||||
className={
|
||||
variant === "row"
|
||||
? "ow-scroll-x flex flex-nowrap gap-1.5 md:flex-wrap"
|
||||
: "flex flex-wrap gap-1.5"
|
||||
}
|
||||
>
|
||||
{shown.map((item, i) => (
|
||||
<button
|
||||
key={`${item.label}-${i}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(item.prompt)}
|
||||
className="max-w-full flex-shrink-0 rounded-full border border-slate-200 bg-white px-2.5 py-[5px] text-left text-[12px] font-medium text-slate-600 transition-colors duration-150 hover:border-[#0039A6]/40 hover:bg-blue-50/70 hover:text-[#0039A6] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/30 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className="block truncate">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
25
src/components/owliver/OwliverThinking.jsx
Normal file
25
src/components/owliver/OwliverThinking.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* The working state: the line Owliver is currently on, and three dots.
|
||||
*
|
||||
* The line comes from the answer being composed ("Checking 27 opportunities…"),
|
||||
* so it names real work on real page data rather than saying "thinking". It is
|
||||
* announced politely so a screen reader follows the same stages a sighted user
|
||||
* watches.
|
||||
*/
|
||||
export default function OwliverThinking({ stage }) {
|
||||
return (
|
||||
<div className="ow-fade-in" aria-live="polite">
|
||||
<p className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-[#0039A6]">Owliver</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span key={stage} className="ow-fade-in text-[12.5px] font-medium text-slate-500">
|
||||
{stage || "Analyzing…"}
|
||||
</span>
|
||||
<span className="flex items-center gap-[3px]" aria-hidden="true">
|
||||
<span className="ow-dot h-1 w-1 rounded-full bg-slate-400" />
|
||||
<span className="ow-dot h-1 w-1 rounded-full bg-slate-400" style={{ animationDelay: "160ms" }} />
|
||||
<span className="ow-dot h-1 w-1 rounded-full bg-slate-400" style={{ animationDelay: "320ms" }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/owliver/blocks/OwliverActions.jsx
Normal file
48
src/components/owliver/blocks/OwliverActions.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, RotateCw } from "lucide-react";
|
||||
import { Label } from "./primitives";
|
||||
|
||||
/**
|
||||
* What to do about the answer.
|
||||
*
|
||||
* Three action shapes, all of them real: a `route` navigates into the
|
||||
* application, a `prompt` asks Owliver the follow-up through the same send path
|
||||
* as typing it, and `retry` re-runs the question that failed. An action with
|
||||
* none of those is not rendered — a button that does nothing is worse than no
|
||||
* button.
|
||||
*/
|
||||
export default function OwliverActions({ block, onPrompt, onRetry, disabled }) {
|
||||
const navigate = useNavigate();
|
||||
const items = (block.items || []).filter((a) => a.route || a.prompt || a.retry);
|
||||
if (!items.length) return null;
|
||||
|
||||
const run = (action) => {
|
||||
if (action.retry) return onRetry?.();
|
||||
if (action.route) return navigate(action.route);
|
||||
if (action.prompt) return onPrompt?.(action.prompt);
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
{block.title && <Label className="mb-1.5">{block.title}</Label>}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((action, i) => (
|
||||
<button
|
||||
key={`${action.label}-${i}`}
|
||||
type="button"
|
||||
disabled={disabled && !action.route}
|
||||
onClick={() => run(action)}
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-semibold text-slate-700 transition-colors duration-150 hover:border-[#0039A6]/40 hover:bg-blue-50/60 hover:text-[#0039A6] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/30 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className="truncate">{action.label}</span>
|
||||
{action.retry ? (
|
||||
<RotateCw className="h-3 w-3 flex-shrink-0 text-[#0039A6]" />
|
||||
) : (
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0 text-[#0039A6]" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
132
src/components/owliver/blocks/OwliverCards.jsx
Normal file
132
src/components/owliver/blocks/OwliverCards.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowUpRight, ChevronDown } from "lucide-react";
|
||||
import { Label, TONE_CHIP, TONE_BG } from "./primitives";
|
||||
|
||||
/**
|
||||
* Record cards — an opportunity, an order, a risk.
|
||||
*
|
||||
* ┌───────────────────────────────┐
|
||||
* │ Vendor consolidation HIGH │
|
||||
* │ Vendor │
|
||||
* │ $96,400 per year │
|
||||
* │ ▇▇▇▇▇▇▇▁▁▁ 34% of total │
|
||||
* │ Status Active · Effort medium │
|
||||
* │ View → │
|
||||
* └───────────────────────────────┘
|
||||
*
|
||||
* A card is interactive in two ways and both are real: the header expands the
|
||||
* record's detail in place, and the action opens the record in the application.
|
||||
* A card with no route renders no action rather than a dead button.
|
||||
*/
|
||||
export default function OwliverCards({ block }) {
|
||||
if (!block.items?.length) return null;
|
||||
return (
|
||||
<section>
|
||||
{block.title && <Label className="mb-1.5">{block.title}</Label>}
|
||||
<div className="space-y-2">
|
||||
{block.items.map((item, i) => (
|
||||
<RecordCard key={item.id || `${item.title}-${i}`} card={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordCard({ card }) {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const expandable = !!card.body;
|
||||
|
||||
const progressWidth = card.progress
|
||||
? Math.max(2, Math.min(100, (Number(card.progress.value) / (Number(card.progress.max) || 1)) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<article className="ow-fade-in overflow-hidden rounded-xl border border-slate-200 bg-white transition-colors duration-150 hover:border-slate-300">
|
||||
<div
|
||||
role={expandable ? "button" : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
onClick={expandable ? () => setOpen((v) => !v) : undefined}
|
||||
onKeyDown={
|
||||
expandable
|
||||
? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }
|
||||
: undefined
|
||||
}
|
||||
className={`px-3 pt-2.5 ${expandable ? "cursor-pointer" : ""}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate text-[13px] font-semibold leading-tight text-slate-900">{card.title}</h4>
|
||||
{card.subtitle && (
|
||||
<p className="mt-0.5 truncate text-[11.5px] leading-tight text-slate-500">{card.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{card.badge && (
|
||||
<span
|
||||
className={`flex-shrink-0 rounded border px-1.5 py-[2px] text-[9.5px] font-bold uppercase tracking-wider ${
|
||||
TONE_CHIP[card.badgeTone] || TONE_CHIP.neutral
|
||||
}`}
|
||||
>
|
||||
{card.badge}
|
||||
</span>
|
||||
)}
|
||||
{expandable && (
|
||||
<ChevronDown
|
||||
className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-slate-400 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{card.value && (
|
||||
<p className="mt-2 flex items-baseline gap-1.5">
|
||||
<span className="text-[20px] font-bold leading-none tabular-nums text-slate-900">{card.value}</span>
|
||||
{card.valueLabel && <span className="text-[11px] font-medium text-slate-500">{card.valueLabel}</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{card.progress && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-500 ease-out ${TONE_BG[card.progress.tone] || TONE_BG.brand}`}
|
||||
style={{ width: `${progressWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
{card.progress.label && (
|
||||
<p className="mt-1 text-[11px] font-medium text-slate-500">{card.progress.label}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && card.body && (
|
||||
<p className="ow-fade-in mt-2 text-[12px] leading-relaxed text-slate-600">{card.body}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(card.meta?.length > 0 || card.route) && (
|
||||
<div className="mt-2.5 flex items-center justify-between gap-2 border-t border-slate-100 px-3 py-2">
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-[11px] text-slate-500">
|
||||
{(card.meta || []).map((m, i) => (
|
||||
<span key={`${m.label}-${i}`} className="inline-flex items-center gap-1">
|
||||
{i > 0 && <span className="text-slate-300">·</span>}
|
||||
<span className="text-slate-400">{m.label}</span>
|
||||
<span className="font-medium text-slate-600">{m.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
{card.route && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(card.route)}
|
||||
className="flex flex-shrink-0 items-center gap-1 rounded text-[11px] font-semibold text-[#0039A6] transition-opacity hover:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/30"
|
||||
>
|
||||
{card.actionLabel || "View"}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
83
src/components/owliver/blocks/OwliverChart.jsx
Normal file
83
src/components/owliver/blocks/OwliverChart.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Area, AreaChart, Bar, BarChart, CartesianGrid, Line, LineChart,
|
||||
ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { Label } from "./primitives";
|
||||
|
||||
/**
|
||||
* A compact chart, drawn with the charting library the application already
|
||||
* ships (recharts) — no new dependency for one panel.
|
||||
*
|
||||
* At panel width almost everything a dashboard chart carries is noise, so this
|
||||
* keeps four things: the series, a light horizontal grid, the category axis and
|
||||
* a value on hover. Y ticks are formatted through the block's own `format`, so
|
||||
* a money series reads "$40K" rather than "40000".
|
||||
*/
|
||||
|
||||
const compact = (v) => {
|
||||
const n = Number(v || 0);
|
||||
if (Math.abs(n) >= 1000) return `${Math.round(n / 1000)}K`;
|
||||
return String(n);
|
||||
};
|
||||
|
||||
export default function OwliverChart({ block }) {
|
||||
const data = block.data || [];
|
||||
if (!data.length) return null;
|
||||
|
||||
const isMoney = block.format === "money";
|
||||
const tick = (v) => (isMoney ? `$${compact(v)}` : compact(v));
|
||||
const tipValue = (v) => (isMoney ? `$${Number(v).toLocaleString("en-US")}` : Number(v).toLocaleString("en-US"));
|
||||
|
||||
const axis = { stroke: "#94A3B8", fontSize: 10, tickLine: false, axisLine: false };
|
||||
const Chart = block.variant === "bar" ? BarChart : block.variant === "line" ? LineChart : AreaChart;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white px-3 py-3">
|
||||
{(block.title || block.summary) && (
|
||||
<div className="mb-2 flex items-baseline justify-between gap-2 px-0.5">
|
||||
{block.title && <Label>{block.title}</Label>}
|
||||
{block.summary && (
|
||||
<span className="flex-shrink-0 text-[11px] font-semibold text-emerald-700">{block.summary}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-[148px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<Chart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -12 }}>
|
||||
<defs>
|
||||
<linearGradient id="owArea" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#0039A6" stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor="#0039A6" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid vertical={false} stroke="#F1F5F9" />
|
||||
<XAxis dataKey="label" {...axis} />
|
||||
<YAxis {...axis} tickFormatter={tick} width={46} />
|
||||
<Tooltip
|
||||
cursor={{ stroke: "#CBD5E1", strokeWidth: 1 }}
|
||||
contentStyle={{
|
||||
borderRadius: 10,
|
||||
border: "1px solid #E2E8F0",
|
||||
boxShadow: "0 4px 16px rgba(15,23,42,0.08)",
|
||||
fontSize: 12,
|
||||
padding: "6px 10px",
|
||||
}}
|
||||
labelStyle={{ fontSize: 10, color: "#94A3B8", fontWeight: 600, textTransform: "uppercase" }}
|
||||
formatter={(value) => [tipValue(value), block.unit || "Value"]}
|
||||
/>
|
||||
|
||||
{block.variant === "bar" && <Bar dataKey="value" fill="#0039A6" radius={[3, 3, 0, 0]} maxBarSize={26} />}
|
||||
{block.variant === "line" && (
|
||||
<Line type="monotone" dataKey="value" stroke="#0039A6" strokeWidth={2} dot={{ r: 2.5, fill: "#0039A6" }} activeDot={{ r: 4 }} />
|
||||
)}
|
||||
{(!block.variant || block.variant === "area") && (
|
||||
<Area type="monotone" dataKey="value" stroke="#0039A6" strokeWidth={2} fill="url(#owArea)" dot={{ r: 2.5, fill: "#0039A6" }} activeDot={{ r: 4 }} />
|
||||
)}
|
||||
</Chart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
121
src/components/owliver/blocks/OwliverFlow.jsx
Normal file
121
src/components/owliver/blocks/OwliverFlow.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowUpRight, ChevronDown } from "lucide-react";
|
||||
import { Label, TONE_BG, TONE_TEXT } from "./primitives";
|
||||
|
||||
/**
|
||||
* A process, drawn as nodes and connectors rather than described in a
|
||||
* paragraph. Vertical by default because the panel is tall and narrow, and each
|
||||
* node is a real control: selecting one opens what that stage is doing right
|
||||
* now, with its own figures and its own way into the application.
|
||||
*
|
||||
* ● Data sources
|
||||
* │
|
||||
* ● Identify spend
|
||||
* │
|
||||
* ▸ Detect opportunity ← selected
|
||||
* ┌──────────────────────┐
|
||||
* │ 27 opportunities │
|
||||
* │ 12 high · 9 med · 6 │
|
||||
* │ View opportunities → │
|
||||
* └──────────────────────┘
|
||||
*/
|
||||
export default function OwliverFlow({ block }) {
|
||||
const navigate = useNavigate();
|
||||
const [active, setActive] = useState(null);
|
||||
const nodes = block.nodes || [];
|
||||
if (!nodes.length) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white px-3 py-3">
|
||||
{block.title && <Label className="mb-2.5">{block.title}</Label>}
|
||||
|
||||
<ol className="space-y-0">
|
||||
{nodes.map((node, i) => {
|
||||
const isOpen = active === node.id;
|
||||
const isLast = i === nodes.length - 1;
|
||||
const detail = node.detail;
|
||||
|
||||
return (
|
||||
<li key={node.id || i} className="relative">
|
||||
<div className="flex gap-2.5">
|
||||
{/* rail */}
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={`mt-[7px] h-2 w-2 flex-shrink-0 rounded-full ring-4 ring-white transition-transform duration-150 ${
|
||||
TONE_BG[node.tone] || TONE_BG.brand
|
||||
} ${isOpen ? "scale-125" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{!isLast && <span className="w-px flex-1 bg-slate-200" aria-hidden="true" />}
|
||||
</div>
|
||||
|
||||
<div className={`min-w-0 flex-1 ${isLast ? "pb-0" : "pb-3"}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive(isOpen ? null : node.id)}
|
||||
disabled={!detail}
|
||||
aria-expanded={isOpen}
|
||||
className={`group flex w-full items-start gap-2 rounded-lg px-2 py-1 text-left transition-colors duration-150 ${
|
||||
detail ? "hover:bg-slate-50" : "cursor-default"
|
||||
} ${isOpen ? "bg-blue-50/70" : ""} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/30`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block truncate text-[12.5px] font-semibold leading-tight ${isOpen ? TONE_TEXT.brand : "text-slate-900"}`}>
|
||||
{node.label}
|
||||
</span>
|
||||
{node.caption && (
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-tight text-slate-500">
|
||||
{node.caption}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{detail && (
|
||||
<ChevronDown
|
||||
className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-slate-300 transition-transform duration-150 group-hover:text-slate-500 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && detail && (
|
||||
<div className="ow-fade-in mt-1.5 rounded-lg border border-slate-200 bg-slate-50/80 px-2.5 py-2">
|
||||
<p className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-slate-400">
|
||||
{detail.title}
|
||||
</p>
|
||||
|
||||
{detail.metrics?.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
{detail.metrics.map((m, j) => (
|
||||
<div key={`${m.label}-${j}`}>
|
||||
<p className="text-[15px] font-bold leading-none tabular-nums text-slate-900">{m.value}</p>
|
||||
<p className="mt-0.5 text-[10.5px] text-slate-500">{m.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(detail.lines || []).map((line, j) => (
|
||||
<p key={j} className="mt-1.5 text-[11.5px] leading-relaxed text-slate-600">{line}</p>
|
||||
))}
|
||||
|
||||
{detail.route && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(detail.route)}
|
||||
className="mt-2 inline-flex items-center gap-1 text-[11px] font-semibold text-[#0039A6] hover:opacity-70"
|
||||
>
|
||||
{detail.actionLabel || "Open"}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
56
src/components/owliver/blocks/OwliverResponse.jsx
Normal file
56
src/components/owliver/blocks/OwliverResponse.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
TextBlock, NoteBlock, MetricBlock, ListBlock, ProgressBlock,
|
||||
StatusBlock, TimelineBlock, CompareBlock,
|
||||
} from "./primitives";
|
||||
import OwliverCards from "./OwliverCards";
|
||||
import OwliverTable from "./OwliverTable";
|
||||
import OwliverChart from "./OwliverChart";
|
||||
import OwliverFlow from "./OwliverFlow";
|
||||
import OwliverActions from "./OwliverActions";
|
||||
|
||||
/**
|
||||
* The response renderer.
|
||||
*
|
||||
* An Owliver answer is an array of typed blocks; this switches on `type` and
|
||||
* nothing else. That is the whole point of the schema — a real model or API
|
||||
* response that emits the same blocks renders here without a line changing, and
|
||||
* an unknown block type is skipped rather than crashing the panel.
|
||||
*
|
||||
* Blocks appear one at a time as the provider reveals them (see
|
||||
* `revealedBlocks` in OwliverProvider), which is what produces the staged
|
||||
* "text first, then the cards" feel without any streaming transport.
|
||||
*/
|
||||
|
||||
const RENDERERS = {
|
||||
text: TextBlock,
|
||||
note: NoteBlock,
|
||||
metric: MetricBlock,
|
||||
list: ListBlock,
|
||||
progress: ProgressBlock,
|
||||
status: StatusBlock,
|
||||
timeline: TimelineBlock,
|
||||
compare: CompareBlock,
|
||||
cards: OwliverCards,
|
||||
table: OwliverTable,
|
||||
chart: OwliverChart,
|
||||
flow: OwliverFlow,
|
||||
actions: OwliverActions,
|
||||
};
|
||||
|
||||
export default function OwliverResponse({ blocks = [], onPrompt, onRetry, disabled }) {
|
||||
if (!blocks.length) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{blocks.map((block, i) => {
|
||||
const Renderer = RENDERERS[block?.type];
|
||||
if (!Renderer) return null;
|
||||
return (
|
||||
<div key={`${block.type}-${i}`} className="ow-block-in">
|
||||
<Renderer block={block} onPrompt={onPrompt} onRetry={onRetry} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/components/owliver/blocks/OwliverTable.jsx
Normal file
145
src/components/owliver/blocks/OwliverTable.jsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowUpRight, ChevronsUpDown } from "lucide-react";
|
||||
import { Label, TONE_CHIP } from "./primitives";
|
||||
|
||||
/**
|
||||
* A compact table, sized for a 360px panel.
|
||||
*
|
||||
* Three rules keep it usable there: at most a handful of columns, the whole
|
||||
* grid scrolls horizontally inside its own container rather than widening the
|
||||
* panel, and a row expands in place instead of opening anything. Sortable
|
||||
* columns sort on the numeric value the block carries, never on the formatted
|
||||
* string — "$96,400" does not sort.
|
||||
*/
|
||||
export default function OwliverTable({ block }) {
|
||||
const navigate = useNavigate();
|
||||
const [sort, setSort] = useState(null); // { key, dir }
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
|
||||
const columns = block.columns || [];
|
||||
const rows = useMemo(() => {
|
||||
const list = [...(block.rows || [])];
|
||||
if (!sort) return list;
|
||||
const dir = sort.dir === "asc" ? 1 : -1;
|
||||
return list.sort((a, b) => {
|
||||
const x = a[sort.key];
|
||||
const y = b[sort.key];
|
||||
if (typeof x === "number" && typeof y === "number") return (x - y) * dir;
|
||||
return String(x ?? "").localeCompare(String(y ?? "")) * dir;
|
||||
});
|
||||
}, [block.rows, sort]);
|
||||
|
||||
if (!rows.length || !columns.length) return null;
|
||||
|
||||
const toggleSort = (key) =>
|
||||
setSort((prev) =>
|
||||
prev?.key === key ? { key, dir: prev.dir === "asc" ? "desc" : "asc" } : { key, dir: "desc" }
|
||||
);
|
||||
|
||||
const display = (row, col) => row[`${col.key}Display`] ?? row[col.key];
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white">
|
||||
{block.title && (
|
||||
<div className="border-b border-slate-100 px-3 py-2">
|
||||
<Label>{block.title}</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ow-scroll-x">
|
||||
<table className="w-full min-w-[300px] border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
scope="col"
|
||||
className={`px-3 py-1.5 text-[9.5px] font-bold uppercase tracking-[0.1em] text-slate-400 ${
|
||||
col.align === "right" ? "text-right" : "text-left"
|
||||
}`}
|
||||
>
|
||||
{col.sortable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(col.key)}
|
||||
className={`inline-flex items-center gap-1 transition-colors hover:text-slate-600 ${
|
||||
sort?.key === col.key ? "text-[#0039A6]" : ""
|
||||
}`}
|
||||
>
|
||||
{col.label}
|
||||
<ChevronsUpDown className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
) : (
|
||||
col.label
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const key = row.id || i;
|
||||
const isOpen = expanded === key;
|
||||
const interactive = !!(row.detail || row.route);
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<tr
|
||||
onClick={interactive ? () => setExpanded(isOpen ? null : key) : undefined}
|
||||
className={`border-b border-slate-50 last:border-0 ${
|
||||
interactive ? "cursor-pointer transition-colors hover:bg-slate-50/70" : ""
|
||||
}`}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-3 py-2 align-top text-[12px] ${
|
||||
col.align === "right"
|
||||
? "whitespace-nowrap text-right font-semibold tabular-nums text-slate-900"
|
||||
: "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{col.badge ? (
|
||||
<span className={`inline-block rounded border px-1.5 py-[1px] text-[9.5px] font-bold uppercase tracking-wide ${TONE_CHIP.neutral}`}>
|
||||
{display(row, col)}
|
||||
</span>
|
||||
) : (
|
||||
display(row, col)
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{isOpen && (
|
||||
<tr className="border-b border-slate-50 bg-slate-50/60">
|
||||
<td colSpan={columns.length} className="px-3 py-2">
|
||||
{row.detail && (
|
||||
<p className="text-[11.5px] leading-relaxed text-slate-600">{row.detail}</p>
|
||||
)}
|
||||
{row.route && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(row.route); }}
|
||||
className="mt-1.5 inline-flex items-center gap-1 text-[11px] font-semibold text-[#0039A6] hover:opacity-70"
|
||||
>
|
||||
View record
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{block.footnote && (
|
||||
<p className="border-t border-slate-100 px-3 py-1.5 text-[10.5px] text-slate-400">{block.footnote}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
248
src/components/owliver/blocks/primitives.jsx
Normal file
248
src/components/owliver/blocks/primitives.jsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isValidRoute } from "@/lib/owliver/actions";
|
||||
|
||||
/**
|
||||
* The small blocks: text, note, metric, list, progress, status, timeline and
|
||||
* compare. Each one renders exactly what its block carries and nothing more —
|
||||
* a block with no data renders nothing rather than an empty frame.
|
||||
*
|
||||
* Typography does the work here. Figures are large, tabular and dark; labels are
|
||||
* small, uppercase and grey; prose sits between the two. That hierarchy is what
|
||||
* makes a narrow panel readable without boxes around everything.
|
||||
*/
|
||||
|
||||
export const TONE_TEXT = {
|
||||
neutral: "text-slate-700",
|
||||
positive: "text-emerald-700",
|
||||
risk: "text-orange-700",
|
||||
warning: "text-amber-700",
|
||||
info: "text-[#0039A6]",
|
||||
brand: "text-[#0039A6]",
|
||||
};
|
||||
|
||||
export const TONE_BG = {
|
||||
neutral: "bg-slate-100",
|
||||
positive: "bg-emerald-500",
|
||||
risk: "bg-orange-500",
|
||||
warning: "bg-amber-500",
|
||||
info: "bg-[#0039A6]",
|
||||
brand: "bg-[#0039A6]",
|
||||
};
|
||||
|
||||
export const TONE_CHIP = {
|
||||
neutral: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
positive: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
risk: "border-orange-200 bg-orange-50 text-orange-700",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
info: "border-[#0039A6]/25 bg-blue-50 text-[#0039A6]",
|
||||
brand: "border-[#0039A6]/25 bg-blue-50 text-[#0039A6]",
|
||||
};
|
||||
|
||||
export const Label = ({ children, className = "" }) => (
|
||||
<p className={`text-[9.5px] font-bold uppercase tracking-[0.12em] text-slate-400 ${className}`}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
/* ---------------------------------------------------------------- text */
|
||||
|
||||
export function TextBlock({ block }) {
|
||||
const navigate = useNavigate();
|
||||
if (!block.content) return null;
|
||||
|
||||
return (
|
||||
<div className="ow-prose text-[13px] leading-[1.62] text-slate-700">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <p className="m-0 mb-2 last:mb-0">{children}</p>,
|
||||
strong: ({ children }) => <strong className="font-semibold text-slate-900">{children}</strong>,
|
||||
ul: ({ children }) => <ul className="m-0 mb-2 list-disc space-y-1 pl-4 last:mb-0">{children}</ul>,
|
||||
a: ({ href, children }) =>
|
||||
isValidRoute(href) ? (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => { e.preventDefault(); navigate(href); }}
|
||||
className="font-medium text-[#0039A6] underline underline-offset-2"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{block.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const NoteBlock = ({ block }) =>
|
||||
block.text ? <p className="text-[11.5px] leading-relaxed text-slate-400">{block.text}</p> : null;
|
||||
|
||||
/* -------------------------------------------------------------- metric */
|
||||
|
||||
/** The headline figure. One number leads; `sub` qualifies it. */
|
||||
export function MetricBlock({ block }) {
|
||||
const trendTone = TONE_TEXT[block.trendTone] || TONE_TEXT.neutral;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white px-3.5 py-3">
|
||||
{block.label && <Label>{block.label}</Label>}
|
||||
|
||||
<div className="mt-1.5 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
||||
<p className="text-[30px] font-bold leading-none tracking-[-0.02em] tabular-nums text-slate-900">
|
||||
{block.value}
|
||||
</p>
|
||||
{block.trend && (
|
||||
<span className={`text-[12.5px] font-semibold tabular-nums ${trendTone}`}>{block.trend}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{block.caption && <p className="mt-1.5 text-[11.5px] text-slate-500">{block.caption}</p>}
|
||||
|
||||
{block.sub?.length > 0 && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 border-t border-slate-100 pt-2.5">
|
||||
{block.sub.map((item, i) => (
|
||||
<div key={`${item.label}-${i}`} className="min-w-0">
|
||||
<p className="truncate text-[10.5px] font-medium uppercase tracking-wide text-slate-400">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[14.5px] font-bold tabular-nums text-slate-800">
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- list */
|
||||
|
||||
export function ListBlock({ block }) {
|
||||
if (!block.items?.length) return null;
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white px-3.5 py-2.5">
|
||||
{block.title && <Label className="mb-1.5">{block.title}</Label>}
|
||||
<dl className="divide-y divide-slate-100">
|
||||
{block.items.map((item, i) => (
|
||||
<div key={`${item.label}-${i}`} className="flex items-center justify-between gap-3 py-1.5 first:pt-0 last:pb-0">
|
||||
<dt className="min-w-0 flex-1 truncate text-[12.5px] text-slate-600">{item.label}</dt>
|
||||
<dd className={`flex-shrink-0 text-[12.5px] font-semibold tabular-nums ${TONE_TEXT[item.tone] || "text-slate-900"}`}>
|
||||
{item.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ progress */
|
||||
|
||||
/** Proportion, drawn. Bars are relative to the block's own max, never to 100. */
|
||||
export function ProgressBlock({ block }) {
|
||||
if (!block.items?.length) return null;
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white px-3.5 py-3">
|
||||
{block.title && <Label className="mb-2.5">{block.title}</Label>}
|
||||
<div className="space-y-2.5">
|
||||
{block.items.map((item, i) => {
|
||||
const max = Number(item.max) || 1;
|
||||
const width = Math.max(2, Math.min(100, (Number(item.value) / max) * 100));
|
||||
return (
|
||||
<div key={`${item.label}-${i}`}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-700">{item.label}</p>
|
||||
<p className="flex-shrink-0 text-[12px] font-semibold tabular-nums text-slate-900">
|
||||
{item.caption ?? item.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-500 ease-out ${TONE_BG[item.tone] || TONE_BG.brand}`}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- status */
|
||||
|
||||
export function StatusBlock({ block }) {
|
||||
const map = {
|
||||
positive: "border-emerald-200 bg-emerald-50/60",
|
||||
risk: "border-orange-200 bg-orange-50/60",
|
||||
warning: "border-amber-200 bg-amber-50/60",
|
||||
neutral: "border-slate-200 bg-slate-50/60",
|
||||
info: "border-[#0039A6]/20 bg-blue-50/60",
|
||||
};
|
||||
return (
|
||||
<section className={`rounded-xl border px-3.5 py-3 ${map[block.tone] || map.neutral}`}>
|
||||
<p className={`text-[10.5px] font-bold uppercase tracking-[0.1em] ${TONE_TEXT[block.tone] || TONE_TEXT.neutral}`}>
|
||||
{block.label}
|
||||
</p>
|
||||
<p className="mt-1.5 text-[13px] leading-relaxed text-slate-700">{block.text}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ timeline */
|
||||
|
||||
export function TimelineBlock({ block }) {
|
||||
if (!block.items?.length) return null;
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white px-3.5 py-3">
|
||||
{block.title && <Label className="mb-2.5">{block.title}</Label>}
|
||||
<ol className="relative space-y-3 border-l border-slate-200 pl-3.5">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={`${item.label}-${i}`} className="relative">
|
||||
<span
|
||||
className={`absolute -left-[19px] top-1 h-2 w-2 rounded-full ring-2 ring-white ${TONE_BG[item.tone] || TONE_BG.neutral}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-slate-400">{item.time}</p>
|
||||
<p className="mt-0.5 text-[12.5px] font-semibold leading-tight text-slate-900">{item.label}</p>
|
||||
{item.caption && <p className="mt-0.5 text-[11.5px] text-slate-500">{item.caption}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- compare */
|
||||
|
||||
/** Two or three figures beside each other, for "which of these" questions. */
|
||||
export function CompareBlock({ block }) {
|
||||
if (!block.items?.length) return null;
|
||||
return (
|
||||
<section>
|
||||
{block.title && <Label className="mb-1.5">{block.title}</Label>}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{block.items.map((item, i) => (
|
||||
<div
|
||||
key={`${item.label}-${i}`}
|
||||
className={`rounded-xl border px-2.5 py-2 ${
|
||||
item.tone === "positive"
|
||||
? "border-emerald-200 bg-emerald-50/50"
|
||||
: "border-slate-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<p className="truncate text-[11px] font-medium text-slate-500" title={item.label}>{item.label}</p>
|
||||
<p className="mt-1 text-[16px] font-bold leading-none tabular-nums text-slate-900">{item.value}</p>
|
||||
{item.caption && <p className="mt-1 text-[10.5px] leading-tight text-slate-500">{item.caption}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from "react";
|
||||
import useOwliverPageContext from "@/components/owliver/useOwliverPageContext";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
@@ -288,6 +289,28 @@ export default function ClientTrendsReport({ events = [], invoices = [] }) {
|
||||
revenuePerLaborHour, laborCostPct,
|
||||
};
|
||||
|
||||
// Owliver answers this page from the figures the report has already computed
|
||||
// for the period the reader has selected — the same objects the charts below
|
||||
// render from, so the panel and the page cannot quote different numbers.
|
||||
useOwliverPageContext("analytics", {
|
||||
events: filteredEvents,
|
||||
invoices: filteredInvoices,
|
||||
metrics,
|
||||
spendTimeline,
|
||||
hoursTimeline,
|
||||
byHub,
|
||||
byDept,
|
||||
byPosition,
|
||||
period,
|
||||
groupBy,
|
||||
activeTab,
|
||||
// Primitives the registration signature can see: the objects above are
|
||||
// rebuilt on every render, and re-registering only when a collection's
|
||||
// length changes would leave Owliver holding a stale period's figures.
|
||||
spend: Math.round(totalSpend),
|
||||
fill: Math.round(fillRatePct),
|
||||
});
|
||||
|
||||
const handleExport = () => {
|
||||
const rows = [
|
||||
['Client Labor Report'], ['Generated', new Date().toISOString()], ['Period', period], [''],
|
||||
|
||||
112
src/index.css
112
src/index.css
@@ -4,12 +4,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Astryx's stylesheet is NOT imported here. It has to load after Tailwind's
|
||||
preflight — which resets `button { padding: 0; background: transparent }` and
|
||||
`* { border-width: 0 }` at the same specificity as Astryx's atomic classes,
|
||||
so loading it first left every Astryx button as bare text — and a CSS
|
||||
`@import` is only valid at the top of a file, where it would lose that race.
|
||||
It is imported after this file in src/main.jsx instead. */
|
||||
|
||||
|
||||
/* :root {
|
||||
@@ -215,7 +209,7 @@ button:focus-visible {
|
||||
cannot move: only its own middle band scrolls, so its header and composer stay
|
||||
put however long an answer runs.
|
||||
|
||||
Below `md` a 300px column would eat the viewport, so the SAME element becomes
|
||||
Below `md` a 380px column would eat the viewport, so the SAME element becomes
|
||||
an out-of-flow sheet at the bottom of the screen. `position: fixed` takes it
|
||||
out of the flex row entirely, which is what lets <main> reclaim the full width
|
||||
without the layout needing to know anything about it.
|
||||
@@ -229,9 +223,9 @@ button:focus-visible {
|
||||
to a hamburger, so 300px is what the page beside it can afford. Declared on
|
||||
:root rather than on an open/closed attribute because the element only exists
|
||||
while Owliver is open — there is no closed state to size. */
|
||||
:root { --krow-owliver-w: 18.75rem; } /* 300px — tablet */
|
||||
@media (min-width: 1024px) { :root { --krow-owliver-w: 21.25rem; } } /* 340px */
|
||||
@media (min-width: 1280px) { :root { --krow-owliver-w: 22.5rem; } } /* 360px */
|
||||
:root { --krow-owliver-w: 23.75rem; } /* 380px — tablet */
|
||||
@media (min-width: 1024px) { :root { --krow-owliver-w: 25rem; } } /* 400px */
|
||||
@media (min-width: 1280px) { :root { --krow-owliver-w: 26.25rem; } } /* 420px */
|
||||
|
||||
.krow-owliver-panel {
|
||||
/* --- sheet form (below md) --- */
|
||||
@@ -240,19 +234,21 @@ button:focus-visible {
|
||||
right: 1rem;
|
||||
bottom: var(--krow-dock-base);
|
||||
height: auto;
|
||||
/* Leave the page it is about visible above it, and cap the sheet so its own
|
||||
middle band scrolls rather than the sheet growing off-screen. */
|
||||
/* The conversation is the surface, so the sheet takes most of the screen —
|
||||
capped, not full, so the page it is about stays visible above it and the
|
||||
sheet's own middle band is what scrolls. */
|
||||
max-height: min(
|
||||
68dvh,
|
||||
78dvh,
|
||||
calc(100dvh - var(--krow-header-h) - var(--krow-dock-base) - 0.5rem)
|
||||
);
|
||||
|
||||
/* Owliver's warm off-white, unchanged between the two placements — the chips
|
||||
and message rows are all designed as white-on-this. */
|
||||
background: #FAF9F5;
|
||||
border: 1px solid #EAE6DE;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
/* A very light neutral, unchanged between the two placements: the insight,
|
||||
order and command cards are all designed as white-on-this, so the panel
|
||||
reads as one control surface rather than a stack of floating cards. */
|
||||
background: #FAFBFC;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.08), 0 1px 4px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@@ -260,7 +256,7 @@ button:focus-visible {
|
||||
left: auto;
|
||||
right: 1.5rem;
|
||||
top: auto;
|
||||
width: min(22.5rem, calc(100vw - 3rem));
|
||||
width: min(23.75rem, calc(100vw - 3rem));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,26 +285,46 @@ button:focus-visible {
|
||||
where the header's expand control appears. */
|
||||
@media (min-width: 1024px) {
|
||||
.krow-owliver-panel[data-expanded="true"] {
|
||||
flex-basis: 27.5rem;
|
||||
width: 27.5rem;
|
||||
flex-basis: 32rem;
|
||||
width: 32rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Astryx's ChatLayout sizes itself to its parent, so the wrapper between it and
|
||||
the panel has to pass the panel's height through rather than collapsing to
|
||||
content. Two rules, and the library handles the scrolling from there. */
|
||||
.krow-owliver-chat { display: flex; flex-direction: column; }
|
||||
.krow-owliver-chat > .astryx-chat-layout { flex: 1 1 auto; min-height: 0; }
|
||||
/* The panel's own scrollport. Its scrollbar is slim so a 400px rail does not
|
||||
lose 15px of readable width to browser chrome. */
|
||||
.krow-owliver-panel ::-webkit-scrollbar { width: 8px; }
|
||||
.krow-owliver-panel ::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 116, 139, 0.28);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
.krow-owliver-panel ::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* ChatMessageList bottom-anchors a short transcript: it puts an empty
|
||||
`flex-grow: 1` spacer before the first message so a two-line exchange sits
|
||||
just above the composer. That is right for a full-page chat, where the
|
||||
conversation is the page. In a 360px rail it reads as a mostly-empty panel
|
||||
with the content stranded at the bottom, so here the spacer is collapsed and
|
||||
content starts under the header. `:empty` picks out the spacer alone — every
|
||||
real message has content — so nothing else in the list is affected. */
|
||||
.krow-owliver-panel .astryx-chat-message-list > div > div:first-child:empty {
|
||||
flex-grow: 0;
|
||||
/* Motion: an insight card arrives, it does not perform. */
|
||||
@keyframes ow-fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.ow-fade-in { animation: ow-fade-in 200ms ease-out both; }
|
||||
|
||||
@keyframes ow-dot {
|
||||
0%, 60%, 100% { opacity: 0.25; transform: translateY(0); }
|
||||
30% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
.ow-dot { animation: ow-dot 1.1s ease-in-out infinite; }
|
||||
|
||||
/* A response block arriving. Each one lands on its own, which is what gives a
|
||||
staged answer its "text first, then the evidence" feel without any streaming
|
||||
transport behind it. */
|
||||
@keyframes ow-block-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.ow-block-in { animation: ow-block-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ow-block-in, .ow-fade-in { animation: none; }
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
@@ -318,20 +334,20 @@ button:focus-visible {
|
||||
Brand indigo is sampled from the KROW logo mark (#3851A4).
|
||||
========================================================================== */
|
||||
[data-owliver] {
|
||||
--ow-canvas: #F7F4EF;
|
||||
--ow-surface: #FFFDF9;
|
||||
--ow-canvas: #FAFBFC;
|
||||
--ow-surface: #FFFFFF;
|
||||
--ow-surface-raised: #FFFFFF;
|
||||
--ow-ink: #1B2333;
|
||||
--ow-ink-muted: #6E7484;
|
||||
--ow-ink-subtle: #9AA0AE;
|
||||
--ow-line: #EAE4DA;
|
||||
--ow-line-strong: #DDD5C7;
|
||||
--ow-brand: #3851A4;
|
||||
--ow-brand-deep: #2A3F82;
|
||||
--ow-brand-wash: #EEF1F9;
|
||||
--ow-ink: #0F172A;
|
||||
--ow-ink-muted: #64748B;
|
||||
--ow-ink-subtle: #94A3B8;
|
||||
--ow-line: #E2E8F0;
|
||||
--ow-line-strong: #CBD5E1;
|
||||
--ow-brand: #0039A6;
|
||||
--ow-brand-deep: #002D85;
|
||||
--ow-brand-wash: #EFF4FE;
|
||||
--ow-danger: #B4442F;
|
||||
--ow-radius: 28px;
|
||||
--ow-radius-sm: 16px;
|
||||
--ow-radius: 20px;
|
||||
--ow-radius-sm: 12px;
|
||||
--ow-shadow: 0 1px 2px rgba(27, 35, 51, 0.04), 0 8px 24px rgba(27, 35, 51, 0.06);
|
||||
--ow-shadow-lift: 0 2px 4px rgba(27, 35, 51, 0.05), 0 18px 48px rgba(27, 35, 51, 0.12);
|
||||
color: var(--ow-ink);
|
||||
|
||||
273
src/lib/owliver/answerModel.js
Normal file
273
src/lib/owliver/answerModel.js
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Owliver answer → structured operational model
|
||||
* ---------------------------------------------
|
||||
* The engine (lib/owliver/owliverEngine.js) is unchanged and still the only
|
||||
* place answers are composed. This module is presentation: it reads the markdown
|
||||
* the engine produced and recovers the operational shape hiding inside it, so
|
||||
* the panel can render
|
||||
*
|
||||
* INSIGHT → EVIDENCE → ACTION
|
||||
*
|
||||
* rather than a paragraph of prose.
|
||||
*
|
||||
* Nothing is invented here. Every number in the model is one the engine already
|
||||
* wrote: the lead's count, the parenthetical seat total, and the `a/b staffed`
|
||||
* figures on each order line. If a line doesn't parse, it falls through to
|
||||
* `paragraphs` and renders as text — degrading to prose is always safe.
|
||||
*
|
||||
* The line formats the engine emits (see the `orders` responder):
|
||||
* - **Business** — Event Name (Hub), 08.17.2026, 6:00 PM – 11:30 PM · 3/10 staffed [id:x]
|
||||
* **Business — Event Name** (Hub), 08.17.2026, 6:00 PM – 11:30 PM · 3/10 staffed [id:x]
|
||||
*/
|
||||
|
||||
import { stripContext } from "./context";
|
||||
import { extractActions } from "./actions";
|
||||
|
||||
const ID_TAG = /\[id:([^\]]+)\]/;
|
||||
const HUB = /\(([^)]+)\)/;
|
||||
const US_DATE = /\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b/;
|
||||
const TIME_RANGE = /(\d{1,2}:\d{2}\s*[AP]M)\s*[–—-]\s*(\d{1,2}:\d{2}\s*[AP]M)/i;
|
||||
const RATIO = /(\d+)\s*\/\s*(\d+)/;
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/** "08.17.2026" → "Aug 17". The year is dropped: these are operational dates. */
|
||||
const shortDate = (raw) => {
|
||||
const m = String(raw || "").match(US_DATE);
|
||||
if (!m) return raw || null;
|
||||
const month = MONTHS[Number(m[1]) - 1];
|
||||
return month ? `${month} ${Number(m[2])}` : raw;
|
||||
};
|
||||
|
||||
const clean = (value) =>
|
||||
String(value || "")
|
||||
.replace(/\*\*/g, "")
|
||||
.replace(/^[\s,·—–-]+|[\s,·—–-]+$/g, "")
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* One order line → a card, or null when the line isn't an order.
|
||||
* @returns {{id, business, name, hub, date, time, assigned, requested, gap, statusText}|null}
|
||||
*/
|
||||
export function parseOrderLine(raw) {
|
||||
let text = String(raw || "").replace(/^\s*[-*]\s+/, "").trim();
|
||||
if (!text) return null;
|
||||
|
||||
const id = text.match(ID_TAG)?.[1] || null;
|
||||
text = text.replace(ID_TAG, "").trim();
|
||||
|
||||
// The trailing segment after the last "·" carries the staffing read.
|
||||
let head = text;
|
||||
let tail = "";
|
||||
const split = text.lastIndexOf("·");
|
||||
if (split > -1) {
|
||||
head = text.slice(0, split);
|
||||
tail = text.slice(split + 1).trim();
|
||||
}
|
||||
|
||||
const ratio = tail.match(RATIO);
|
||||
const assigned = ratio ? Number(ratio[1]) : null;
|
||||
const requested = ratio ? Number(ratio[2]) : null;
|
||||
const statusText = ratio ? null : clean(tail) || null;
|
||||
|
||||
// A line with neither a ratio nor an id is prose, not an order.
|
||||
if (assigned == null && !id) return null;
|
||||
|
||||
const bold = head.match(/\*\*(.+?)\*\*/)?.[1] || "";
|
||||
let rest = head.replace(/\*\*(.+?)\*\*/, " ").trim();
|
||||
|
||||
let business = bold;
|
||||
let name = "";
|
||||
const dash = bold.indexOf("—");
|
||||
if (dash > -1) {
|
||||
business = bold.slice(0, dash);
|
||||
name = bold.slice(dash + 1);
|
||||
}
|
||||
|
||||
const hub = rest.match(HUB)?.[1] || null;
|
||||
if (hub) rest = rest.replace(HUB, " ");
|
||||
|
||||
const dateRaw = rest.match(US_DATE)?.[0] || null;
|
||||
if (dateRaw) rest = rest.replace(US_DATE, " ");
|
||||
|
||||
const timeMatch = rest.match(TIME_RANGE);
|
||||
const time = timeMatch ? `${timeMatch[1]} – ${timeMatch[2]}` : null;
|
||||
if (timeMatch) rest = rest.replace(TIME_RANGE, " ");
|
||||
|
||||
if (!name) name = rest;
|
||||
|
||||
const gap =
|
||||
assigned != null && requested != null ? Math.max(0, requested - assigned) : 0;
|
||||
|
||||
return {
|
||||
id,
|
||||
business: clean(business) || clean(name) || "Order",
|
||||
name: clean(name),
|
||||
hub: clean(hub) || null,
|
||||
date: dateRaw ? shortDate(dateRaw) : null,
|
||||
time,
|
||||
assigned,
|
||||
requested,
|
||||
gap,
|
||||
statusText,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ insight */
|
||||
|
||||
/** What the answer is *about*, taken from the words the engine chose. */
|
||||
const LABELS = [
|
||||
[/orders? on this page|on this page —/i, "Order summary", "neutral"],
|
||||
[/staffing attention|need[s]? .*attention|headcount gap/i, "Staffing attention", "risk"],
|
||||
[/at risk|unstaffed/i, "Upcoming at risk", "risk"],
|
||||
[/partially staffed/i, "Partially staffed", "risk"],
|
||||
[/pending confirmation|pending/i, "Pending confirmation", "pending"],
|
||||
[/draft/i, "Draft orders", "neutral"],
|
||||
[/conflict|double.?book/i, "Scheduling conflicts", "risk"],
|
||||
[/scheduled ahead|scheduled/i, "Scheduled ahead", "neutral"],
|
||||
[/seats? (still )?open|open seat/i, "Open headcount", "risk"],
|
||||
];
|
||||
|
||||
/** The reassuring counterpart, when the answer is that nothing is wrong. */
|
||||
const CLEAR_LABELS = [
|
||||
[/conflict|double.?book/i, "No conflicts"],
|
||||
[/pending/i, "Nothing pending"],
|
||||
[/draft/i, "No drafts"],
|
||||
[/at risk|unstaffed/i, "No immediate risk"],
|
||||
[/partially staffed|attention|short of headcount/i, "No staffing gaps"],
|
||||
];
|
||||
|
||||
const NEGATIVE_OPENER = /^(no |nothing |none |every |all |there (is|are) no)/i;
|
||||
|
||||
const labelFor = (text) => {
|
||||
const hit = LABELS.find(([pattern]) => pattern.test(text));
|
||||
return hit ? { label: hit[1], tone: hit[2] } : { label: "Order insight", tone: "neutral" };
|
||||
};
|
||||
|
||||
const clearLabelFor = (text) => {
|
||||
const hit = CLEAR_LABELS.find(([pattern]) => pattern.test(text));
|
||||
return hit ? hit[1] : "All clear";
|
||||
};
|
||||
|
||||
/**
|
||||
* The lead sentence, read as a metric.
|
||||
* "3 orders need staffing attention, ranked by open headcount gap:" →
|
||||
* { count: 3, unit: "orders", label: "Staffing attention", tone: "risk" }
|
||||
*/
|
||||
function parseLead(text, cards) {
|
||||
const trimmed = String(text || "").replace(/[:\s]+$/, "").trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const positive = cards.length === 0 && NEGATIVE_OPENER.test(trimmed);
|
||||
if (positive) {
|
||||
return {
|
||||
tone: "positive",
|
||||
label: clearLabelFor(trimmed),
|
||||
count: null,
|
||||
unit: null,
|
||||
secondary: null,
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
// "4 upcoming orders …" keeps its qualifier; "3 orders need …" does not pick
|
||||
// up the verb after it.
|
||||
const opening =
|
||||
trimmed.match(
|
||||
/^(\d+)\s+((?:[a-zA-Z]+\s+)?(?:orders?|shifts?|seats?|conflicts?|invoices?|positions?|candidates?|workers?))\b/i
|
||||
) || trimmed.match(/^(\d+)\s+([a-zA-Z]+)\b/);
|
||||
const { label, tone } = labelFor(trimmed);
|
||||
|
||||
// "(18 open seats total)" — the engine's own second figure, when it gave one.
|
||||
let secondary = null;
|
||||
const paren = trimmed.match(/\(([^)]*\d[^)]*)\)/);
|
||||
if (paren) {
|
||||
const num = paren[1].match(/\d+/);
|
||||
if (num) {
|
||||
secondary = {
|
||||
value: Number(num[0]),
|
||||
unit: clean(paren[1].replace(/\d+/, "").replace(/\btotal\b/i, "")) || "open",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// "…so 51 seats are still open." — the engine's own figure, mid-sentence.
|
||||
const stated = trimmed.match(/(\d+)\s+(?:open\s+seats?|seats?)\s+(?:are|is)?\s*(?:still\s+)?open/i);
|
||||
if (stated) {
|
||||
secondary = { value: Number(stated[1]), unit: "seats open" };
|
||||
} else if (cards.length) {
|
||||
const gaps = cards.reduce((sum, card) => sum + (card.gap || 0), 0);
|
||||
if (gaps > 0) secondary = { value: gaps, unit: "seats open" };
|
||||
}
|
||||
}
|
||||
|
||||
// No figure to lead with and nothing resolved: this is conversational prose
|
||||
// ("I can raise a new order…"), and dressing it as a metric card would be a
|
||||
// lie about what the answer contains.
|
||||
if (!opening && !secondary) return { tone: "prose", label: null, count: null, unit: null, secondary: null, text: trimmed };
|
||||
|
||||
return {
|
||||
tone,
|
||||
label,
|
||||
count: opening ? Number(opening[1]) : null,
|
||||
unit: opening ? opening[2].trim() : null,
|
||||
secondary,
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- model */
|
||||
|
||||
/**
|
||||
* Turn one assistant message into blocks the panel can render.
|
||||
*
|
||||
* @param {string} content raw markdown from the engine
|
||||
* @returns {{insight, cards, paragraphs, actions, raw}}
|
||||
*/
|
||||
export function buildAnswerModel(content) {
|
||||
const raw = stripContext(content);
|
||||
if (!raw) return { insight: null, cards: [], paragraphs: [], actions: [], raw: "" };
|
||||
|
||||
const lines = raw.split("\n");
|
||||
const cards = [];
|
||||
const paragraphs = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const card = parseOrderLine(trimmed);
|
||||
if (card) {
|
||||
cards.push(card);
|
||||
continue;
|
||||
}
|
||||
paragraphs.push(trimmed.replace(/^[-*]\s+/, ""));
|
||||
continue;
|
||||
}
|
||||
|
||||
// A standalone bolded record ("which order needs the most attention?").
|
||||
if (/^\*\*/.test(trimmed) && (RATIO.test(trimmed) || ID_TAG.test(trimmed))) {
|
||||
const card = parseOrderLine(trimmed);
|
||||
if (card) {
|
||||
cards.push(card);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
paragraphs.push(trimmed);
|
||||
}
|
||||
|
||||
const lead = paragraphs.length ? paragraphs[0] : "";
|
||||
const insight = parseLead(lead, cards);
|
||||
const body = insight ? paragraphs.slice(1) : paragraphs;
|
||||
|
||||
return {
|
||||
insight,
|
||||
cards,
|
||||
paragraphs: body,
|
||||
actions: extractActions(raw),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
export default buildAnswerModel;
|
||||
96
src/lib/owliver/blocks.js
Normal file
96
src/lib/owliver/blocks.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Owliver response blocks
|
||||
* -----------------------
|
||||
* An Owliver answer is a list of typed blocks, never a string of markup. The
|
||||
* renderer (components/owliver/blocks) switches on `type` and nothing else, so
|
||||
* the same surface can be driven by today's local knowledge packs and by a real
|
||||
* model/API tomorrow — the transport changes, the schema does not.
|
||||
*
|
||||
* type OwliverBlock =
|
||||
* | { type: "text", content }
|
||||
* | { type: "metric", label, value, trend?, caption?, sub? }
|
||||
* | { type: "cards", items }
|
||||
* | { type: "table", columns, rows }
|
||||
* | { type: "chart", variant, data }
|
||||
* | { type: "flow", nodes }
|
||||
* | { type: "actions", items }
|
||||
* | { type: "list", items }
|
||||
* | { type: "progress", items }
|
||||
* | { type: "status", tone, label, text }
|
||||
* | { type: "timeline", items }
|
||||
* | { type: "compare", items }
|
||||
* | { type: "note", text }
|
||||
*
|
||||
* The factories below exist so a knowledge pack reads as a description of an
|
||||
* answer rather than as object literals, and so a typo in a block type fails
|
||||
* where it is written instead of silently rendering nothing.
|
||||
*/
|
||||
|
||||
export const BLOCK_TYPES = [
|
||||
"text", "metric", "cards", "table", "chart", "flow",
|
||||
"actions", "list", "progress", "status", "timeline", "compare", "note",
|
||||
];
|
||||
|
||||
/** Tone vocabulary shared by every block that carries one. */
|
||||
export const TONES = ["neutral", "positive", "risk", "warning", "info"];
|
||||
|
||||
export const text = (content) => ({ type: "text", content });
|
||||
|
||||
export const note = (content) => ({ type: "note", text: content });
|
||||
|
||||
/**
|
||||
* The headline figure of an answer.
|
||||
* `sub` carries the qualifying figures ("Monthly $42,800 · Annualized …").
|
||||
*/
|
||||
export const metric = ({ label, value, trend, trendTone = "neutral", caption, sub = [] }) => ({
|
||||
type: "metric", label, value, trend, trendTone, caption, sub,
|
||||
});
|
||||
|
||||
/** Records: opportunities, orders, risks — anything the user can open. */
|
||||
export const cards = (items, { title } = {}) => ({ type: "cards", title, items });
|
||||
|
||||
export const table = ({ title, columns, rows, footnote }) => ({
|
||||
type: "table", title, columns, rows, footnote,
|
||||
});
|
||||
|
||||
export const chart = ({ title, variant = "line", data, unit, summary, format = "number" }) => ({
|
||||
type: "chart", title, variant, data, unit, summary, format,
|
||||
});
|
||||
|
||||
/** A process, drawn. `nodes` are ordered; each may carry a `detail` panel. */
|
||||
export const flow = ({ title, nodes, orientation = "vertical" }) => ({
|
||||
type: "flow", title, nodes, orientation,
|
||||
});
|
||||
|
||||
/** Buttons. A route navigates; a prompt asks Owliver the follow-up. */
|
||||
export const actions = (items, { title } = {}) => ({ type: "actions", title, items });
|
||||
|
||||
export const list = ({ title, items }) => ({ type: "list", title, items });
|
||||
|
||||
export const progress = ({ title, items }) => ({ type: "progress", title, items });
|
||||
|
||||
export const status = ({ tone = "positive", label, text: body }) => ({
|
||||
type: "status", tone, label, text: body,
|
||||
});
|
||||
|
||||
export const timeline = ({ title, items }) => ({ type: "timeline", title, items });
|
||||
|
||||
export const compare = ({ title, items }) => ({ type: "compare", title, items });
|
||||
|
||||
/** Drop falsy entries so a pack can write `cond && block(...)` inline. */
|
||||
export const blocks = (...entries) => entries.flat().filter(Boolean);
|
||||
|
||||
/* ------------------------------------------------------------- formatting */
|
||||
|
||||
export const money = (value, { compact = false } = {}) => {
|
||||
const n = Number(value || 0);
|
||||
if (compact && Math.abs(n) >= 1000) {
|
||||
const k = n / 1000;
|
||||
return `$${(Math.abs(k) >= 100 ? Math.round(k) : Number(k.toFixed(1)))}K`;
|
||||
}
|
||||
return `$${Math.round(n).toLocaleString("en-US")}`;
|
||||
};
|
||||
|
||||
export const pct = (value, digits = 0) => `${Number(value || 0).toFixed(digits)}%`;
|
||||
|
||||
export const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
||||
550
src/lib/owliver/knowledge/analytics.js
Normal file
550
src/lib/owliver/knowledge/analytics.js
Normal file
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Operational Intelligence (Analytics) — page knowledge
|
||||
* -----------------------------------------------------
|
||||
* Answers about the Reports page from the figures that page has already
|
||||
* computed: spend against budget, hours scheduled against hours worked, fill
|
||||
* rate, and where the money went by hub, department and position.
|
||||
*
|
||||
* Every figure here is read from `signals` — the workspace derived in
|
||||
* `analyticsWorkspace.js` from the orders and invoices the page is showing. A
|
||||
* breakdown the page has no records for is not drawn, and the answer says so
|
||||
* rather than modelling a distribution that nothing supports.
|
||||
*/
|
||||
|
||||
import {
|
||||
blocks, text, note, metric, chart, table, actions, list, progress, status,
|
||||
money, pct, plural,
|
||||
} from "../blocks";
|
||||
|
||||
const n = (v) => Number(v || 0);
|
||||
|
||||
const EMPTY = {
|
||||
view: { period: "all", periodLabel: "all time", groupBy: "monthly", activeTab: "executive" },
|
||||
totals: {}, timeline: [], hoursTimeline: [], byHub: [], byDept: [], byPosition: [],
|
||||
};
|
||||
|
||||
const workspace = (signals) => (signals && signals.totals ? signals : EMPTY);
|
||||
|
||||
const completionRate = (t) => (n(t.events) ? (n(t.completed) / n(t.events)) * 100 : 0);
|
||||
|
||||
const nothingToRead = (topic, what) => ({
|
||||
topic,
|
||||
stages: ["Reading the numbers…"],
|
||||
blocks: blocks(
|
||||
text(`This page has no ${what} to read yet.`),
|
||||
note("Owliver reads the orders and invoices the Reports page has loaded for the selected period. Widening the period is usually enough."),
|
||||
actions([{ label: "Open reports", route: "/Reports" }])
|
||||
),
|
||||
followUps: [],
|
||||
});
|
||||
|
||||
/* ================================================================= answers */
|
||||
|
||||
const answerSummary = (d) => {
|
||||
const t = d.totals;
|
||||
if (!t.events && !t.invoices) return nothingToRead("summary", "orders or invoices");
|
||||
|
||||
return {
|
||||
topic: "summary",
|
||||
stages: [`Reading ${plural(t.events, "order", "orders")} and ${plural(t.invoices, "invoice", "invoices")}…`, "Comparing against budget…"],
|
||||
blocks: blocks(
|
||||
text(`Across the ${d.view.periodLabel}, **${money(t.spend)}** of labor spend covers ${plural(t.events, "order", "orders")} — ${plural(t.completed, "completed", "completed")}, ${t.open} still open — at a ${pct(t.fillRatePct, 1)} fill rate.`),
|
||||
metric({
|
||||
label: "Labor spend",
|
||||
value: money(t.spend),
|
||||
trend: t.spendDelta == null ? undefined : `${t.spendDelta >= 0 ? "↑" : "↓"} ${pct(Math.abs(t.spendDelta), 1)}`,
|
||||
trendTone: t.spendDelta == null ? "neutral" : t.spendDelta > 0 ? "risk" : "positive",
|
||||
caption: t.budget ? `${pct(t.budgetUsedPct, 1)} of a ${money(t.budget)} budget` : d.view.periodLabel,
|
||||
sub: [
|
||||
{ label: "Invoices", value: String(t.invoices) },
|
||||
{ label: "Labor cost", value: t.laborCostPct ? pct(t.laborCostPct, 1) : "—" },
|
||||
],
|
||||
}),
|
||||
list({
|
||||
title: "Where it stands",
|
||||
items: [
|
||||
{ label: "Orders completed", value: `${t.completed} of ${t.events}`, tone: t.open ? "neutral" : "positive" },
|
||||
{ label: "Fill rate", value: pct(t.fillRatePct, 1), tone: t.fillRatePct >= 95 ? "positive" : "risk" },
|
||||
t.openShifts ? { label: "Open shifts", value: String(t.openShifts), tone: "risk" } : null,
|
||||
t.scheduledHours ? { label: "Hours scheduled vs worked", value: `${t.scheduledHours} / ${t.workedHours}` } : null,
|
||||
t.budget ? { label: "Budget remaining", value: money(t.budgetLeft), tone: t.budgetLeft >= 0 ? "positive" : "risk" } : null,
|
||||
].filter(Boolean),
|
||||
}),
|
||||
actions([
|
||||
d.byDept.length ? { label: "Break it down by department", prompt: "Break spend down by department" } : null,
|
||||
{ label: "What needs attention?", prompt: "What in the numbers needs my attention?" },
|
||||
].filter(Boolean))
|
||||
),
|
||||
followUps: [
|
||||
d.timeline.length > 1 && { label: "What changed over the period?", prompt: "What changed over the period?" },
|
||||
d.byDept.length && { label: "Which department costs most?", prompt: "Which department costs the most?" },
|
||||
{ label: "What needs attention?", prompt: "What in the numbers needs my attention?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerChanged = (d) => {
|
||||
const t = d.totals;
|
||||
if (d.timeline.length < 2) {
|
||||
return {
|
||||
topic: "changed",
|
||||
stages: ["Reading order and invoice history…"],
|
||||
blocks: blocks(
|
||||
text("There is not enough dated history in the selected period to read a trend."),
|
||||
note(d.timeline.length
|
||||
? "Only one bucket of spend falls inside this period, so there is nothing to compare it against."
|
||||
: "No invoice or order in this period carries a date."),
|
||||
actions([{ label: "Give me a summary", prompt: "Give me a summary" }])
|
||||
),
|
||||
followUps: [{ label: "Give me a summary", prompt: "Give me a summary" }],
|
||||
};
|
||||
}
|
||||
|
||||
const first = d.timeline[0];
|
||||
const last = d.timeline[d.timeline.length - 1];
|
||||
const growth = first.value ? ((last.value - first.value) / first.value) * 100 : null;
|
||||
|
||||
return {
|
||||
topic: "changed",
|
||||
stages: ["Reading order and invoice history…", "Comparing against the prior period…"],
|
||||
blocks: blocks(
|
||||
text(`Spend moved from ${money(first.value, { compact: true })} in ${first.label} to **${money(last.value, { compact: true })}** in ${last.label}${growth == null ? "" : `, ${growth >= 0 ? "up" : "down"} ${pct(Math.abs(growth), 1)} across the period`}.`),
|
||||
chart({
|
||||
title: "Labor spend",
|
||||
variant: "area",
|
||||
data: d.timeline,
|
||||
format: "money",
|
||||
summary: `${d.view.periodLabel} · ${plural(d.timeline.length, "bucket", "buckets")}`,
|
||||
}),
|
||||
metric({
|
||||
label: "Completion rate",
|
||||
value: pct(completionRate(t), 1),
|
||||
caption: `${t.completed} of ${t.events} orders`,
|
||||
sub: [
|
||||
{ label: "Invoices", value: String(t.invoices) },
|
||||
{ label: "Billed", value: money(t.spend, { compact: true }) },
|
||||
],
|
||||
}),
|
||||
actions([
|
||||
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
|
||||
{ label: "Open reports", route: "/Reports" },
|
||||
])
|
||||
),
|
||||
followUps: [
|
||||
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
|
||||
{ label: "What needs attention?", prompt: "What in the numbers needs my attention?" },
|
||||
d.byDept.length && { label: "Which department costs most?", prompt: "Which department costs the most?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerRevenue = (d) => {
|
||||
const t = d.totals;
|
||||
if (!t.invoices && !t.spend) return nothingToRead("revenue", "invoices");
|
||||
|
||||
return {
|
||||
topic: "revenue",
|
||||
stages: [`Reading ${plural(t.invoices, "invoice", "invoices")}…`],
|
||||
blocks: blocks(
|
||||
text(`**${money(t.spend)}** billed across ${plural(t.invoices, "invoice", "invoices")} in the ${d.view.periodLabel}.`),
|
||||
d.timeline.length > 1 ? chart({
|
||||
title: "Billed value",
|
||||
variant: "area",
|
||||
data: d.timeline,
|
||||
format: "money",
|
||||
summary: `${plural(d.timeline.length, "bucket", "buckets")} across the period`,
|
||||
}) : null,
|
||||
metric({
|
||||
label: "Average invoice",
|
||||
value: money(t.invoices ? t.spend / t.invoices : 0),
|
||||
caption: `per invoice, ${d.view.periodLabel}`,
|
||||
sub: [
|
||||
t.revenuePerLaborHour ? { label: "Rev / labor hr", value: money(t.revenuePerLaborHour) } : null,
|
||||
t.laborCostPct ? { label: "Labor cost", value: pct(t.laborCostPct, 1) } : null,
|
||||
].filter(Boolean),
|
||||
}),
|
||||
actions([{ label: "Open invoices", route: "/Invoices" }])
|
||||
),
|
||||
followUps: [
|
||||
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
|
||||
d.timeline.length > 1 && { label: "What changed over the period?", prompt: "What changed over the period?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerMargin = (d) => {
|
||||
const t = d.totals;
|
||||
|
||||
/**
|
||||
* The leak, from the three things this page can actually measure: hours paid
|
||||
* for above what was scheduled, shifts left open, and orders that went short.
|
||||
* Each is worth a number only where the records carry one.
|
||||
*/
|
||||
const leaks = [
|
||||
t.overtimeHours > 0 && {
|
||||
label: "Hours above schedule",
|
||||
value: t.overtimeHours,
|
||||
caption: `${plural(t.overtimeHours, "hour", "hours")} worked beyond ${t.scheduledHours} scheduled`,
|
||||
},
|
||||
t.openShifts > 0 && {
|
||||
label: "Open shifts",
|
||||
value: t.openShifts,
|
||||
caption: `${plural(t.openShifts, "shift", "shifts")} unfilled at a ${pct(t.fillRatePct, 1)} fill rate`,
|
||||
},
|
||||
t.noShows > 0 && {
|
||||
label: "Orders that went short",
|
||||
value: t.noShows,
|
||||
caption: `${plural(t.noShows, "order", "orders")} ran below requested headcount`,
|
||||
},
|
||||
t.budget && t.budgetLeft < 0 && {
|
||||
label: "Over budget",
|
||||
value: Math.abs(t.budgetLeft),
|
||||
caption: `${money(Math.abs(t.budgetLeft))} above the ${money(t.budget)} budget`,
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
if (!leaks.length) {
|
||||
return {
|
||||
topic: "margin",
|
||||
stages: ["Checking spend against billed value…"],
|
||||
blocks: blocks(
|
||||
text("Nothing on this page is currently leaking margin."),
|
||||
status({
|
||||
tone: "positive",
|
||||
label: "No leak detected",
|
||||
text: `Every shift was filled, no hours were worked above schedule, and spend is ${t.budget ? `${pct(t.budgetUsedPct, 1)} of budget` : "within the tracked window"}.`,
|
||||
}),
|
||||
actions([{ label: "Give me a summary", prompt: "Give me a summary" }])
|
||||
),
|
||||
followUps: [{ label: "Give me a summary", prompt: "Give me a summary" }],
|
||||
};
|
||||
}
|
||||
|
||||
const worst = leaks[0];
|
||||
return {
|
||||
topic: "margin",
|
||||
stages: ["Checking spend against billed value…"],
|
||||
blocks: blocks(
|
||||
text(`${plural(leaks.length, "thing is", "things are")} costing margin on the records this page tracks:`),
|
||||
progress({
|
||||
title: "Where it is going",
|
||||
items: leaks.map((l) => ({
|
||||
label: l.label,
|
||||
value: l.value,
|
||||
max: Math.max(...leaks.map((x) => x.value)),
|
||||
caption: l.caption,
|
||||
tone: l === worst ? "brand" : "neutral",
|
||||
})),
|
||||
}),
|
||||
t.hoursSavings ? note(`Hours saved against schedule are worth ${money(t.hoursSavings)} at the average rate this period.`) : null,
|
||||
actions([
|
||||
{ label: "Open Saving Engine", route: "/SavingsEngine" },
|
||||
{ label: "Open reports", route: "/Reports" },
|
||||
])
|
||||
),
|
||||
followUps: [
|
||||
d.byDept.length && { label: "Which department costs most?", prompt: "Which department costs the most?" },
|
||||
{ label: "What needs attention?", prompt: "What in the numbers needs my attention?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerAttention = (d) => {
|
||||
const t = d.totals;
|
||||
if (!t.events && !t.invoices) return nothingToRead("attention", "orders or invoices");
|
||||
|
||||
const flags = [
|
||||
t.open > 0 && { label: "Orders still open", value: String(t.open), tone: "risk" },
|
||||
t.openShifts > 0 && { label: "Unfilled shifts", value: String(t.openShifts), tone: "risk" },
|
||||
t.noShows > 0 && { label: "Orders that went short", value: String(t.noShows), tone: "risk" },
|
||||
t.budget && t.budgetLeft < 0 && { label: "Over budget by", value: money(Math.abs(t.budgetLeft)), tone: "risk" },
|
||||
t.fillRatePct < 95 && { label: "Fill rate", value: pct(t.fillRatePct, 1), tone: "risk" },
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
topic: "attention",
|
||||
stages: ["Scanning for anomalies…"],
|
||||
blocks: blocks(
|
||||
text(flags.length
|
||||
? `${plural(flags.length, "thing needs", "things need")} a look on this page.`
|
||||
: `Nothing on this page needs intervention — ${t.completed} of ${plural(t.events, "order", "orders")} completed at a ${pct(t.fillRatePct, 1)} fill rate.`),
|
||||
list({
|
||||
title: flags.length ? "Worth a look" : "Where it stands",
|
||||
items: flags.length ? flags : [
|
||||
{ label: "Completed", value: String(t.completed), tone: "positive" },
|
||||
{ label: "Fill rate", value: pct(t.fillRatePct, 1), tone: "positive" },
|
||||
{ label: "Invoices tracked", value: String(t.invoices) },
|
||||
],
|
||||
}),
|
||||
actions([{ label: "Open reports", route: "/Reports" }])
|
||||
),
|
||||
followUps: [
|
||||
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
|
||||
d.timeline.length > 1 && { label: "What changed over the period?", prompt: "What changed over the period?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The breakdown answers. One responder, three groupings, chosen by the words
|
||||
* used — a department question and a hub question are the same reading of the
|
||||
* same records at a different grain.
|
||||
*/
|
||||
const breakdown = (d, key, label) => {
|
||||
const rows = d[key] || [];
|
||||
if (!rows.length) return nothingToRead(`by-${key}`, `${label} breakdown`);
|
||||
|
||||
const total = rows.reduce((s, r) => s + n(r.spend), 0);
|
||||
const lead = rows[0];
|
||||
|
||||
return {
|
||||
topic: "breakdown",
|
||||
stages: [`Grouping spend by ${label}…`],
|
||||
blocks: blocks(
|
||||
text(`**${lead.name}** is the largest ${label} on this page at ${money(lead.spend)}${total ? `, ${pct((n(lead.spend) / total) * 100, 1)} of ${money(total)} tracked` : ""}.`),
|
||||
table({
|
||||
title: `Spend by ${label}`,
|
||||
columns: [
|
||||
{ key: "name", label: label.charAt(0).toUpperCase() + label.slice(1), align: "left" },
|
||||
{ key: "spend", label: "Spend", align: "right", sortable: true, numeric: true },
|
||||
{ key: "count", label: key === "byHub" ? "Shifts" : "Headcount", align: "right", numeric: true },
|
||||
],
|
||||
rows: rows.map((r, i) => ({
|
||||
id: `${key}-${i}`,
|
||||
name: r.name,
|
||||
spend: n(r.spend),
|
||||
spendDisplay: money(r.spend),
|
||||
count: n(r.count ?? r.shifts),
|
||||
})),
|
||||
footnote: "Sort by spend",
|
||||
}),
|
||||
actions([{ label: "Open reports", route: "/Reports" }])
|
||||
),
|
||||
followUps: [
|
||||
key !== "byDept" && d.byDept.length && { label: "Which department costs most?", prompt: "Which department costs the most?" },
|
||||
key !== "byPosition" && d.byPosition.length && { label: "Which position costs most?", prompt: "Which position costs the most?" },
|
||||
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerByDept = (d) => breakdown(d, "byDept", "department");
|
||||
const answerByPosition = (d) => breakdown(d, "byPosition", "position");
|
||||
const answerByHub = (d) => breakdown(d, "byHub", "hub");
|
||||
|
||||
const answerHours = (d) => {
|
||||
const t = d.totals;
|
||||
if (!t.scheduledHours) return nothingToRead("hours", "scheduled hours");
|
||||
|
||||
return {
|
||||
topic: "hours",
|
||||
stages: ["Reading scheduled against worked hours…"],
|
||||
blocks: blocks(
|
||||
text(`**${plural(t.scheduledHours, "hour", "hours")} scheduled** against ${plural(t.workedHours, "hour", "hours")} worked — a ${t.savedHours >= 0 ? "saving" : "overrun"} of ${plural(Math.abs(t.savedHours), "hour", "hours")}.`),
|
||||
metric({
|
||||
label: t.savedHours >= 0 ? "Hours saved" : "Hours over",
|
||||
value: String(Math.abs(t.savedHours)),
|
||||
caption: t.hoursSavings ? `${money(Math.abs(t.hoursSavings))} at the period's average rate` : d.view.periodLabel,
|
||||
sub: [
|
||||
{ label: "Scheduled", value: String(t.scheduledHours) },
|
||||
{ label: "Worked", value: String(t.workedHours) },
|
||||
],
|
||||
}),
|
||||
list({
|
||||
title: "Coverage",
|
||||
items: [
|
||||
{ label: "Fill rate", value: pct(t.fillRatePct, 1), tone: t.fillRatePct >= 95 ? "positive" : "risk" },
|
||||
t.openShifts ? { label: "Open shifts", value: String(t.openShifts), tone: "risk" } : null,
|
||||
t.overtimeHours ? { label: "Hours above schedule", value: String(t.overtimeHours), tone: "risk" } : null,
|
||||
].filter(Boolean),
|
||||
}),
|
||||
actions([{ label: "Open reports", route: "/Reports" }])
|
||||
),
|
||||
followUps: [
|
||||
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
|
||||
d.byPosition.length && { label: "Which position costs most?", prompt: "Which position costs the most?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerVolume = (d) => {
|
||||
const t = d.totals;
|
||||
if (!t.events) return nothingToRead("volume", "orders");
|
||||
|
||||
return {
|
||||
topic: "volume",
|
||||
stages: ["Reading order history…"],
|
||||
blocks: blocks(
|
||||
text(`${plural(t.events, "order", "orders")} tracked in the ${d.view.periodLabel}, ${t.completed} completed.`),
|
||||
table({
|
||||
title: "Tracked volume",
|
||||
columns: [
|
||||
{ key: "label", label: "Measure", align: "left" },
|
||||
{ key: "value", label: "Count", align: "right", sortable: true, numeric: true },
|
||||
],
|
||||
rows: [
|
||||
{ id: "events", label: "Orders tracked", value: t.events, valueDisplay: String(t.events) },
|
||||
{ id: "completed", label: "Completed", value: t.completed, valueDisplay: String(t.completed) },
|
||||
{ id: "open", label: "Still open", value: t.open, valueDisplay: String(t.open) },
|
||||
{ id: "invoices", label: "Invoices", value: t.invoices, valueDisplay: String(t.invoices) },
|
||||
],
|
||||
})
|
||||
),
|
||||
followUps: [
|
||||
{ label: "Give me a summary", prompt: "Give me a summary" },
|
||||
{ label: "What needs attention?", prompt: "What in the numbers needs my attention?" },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/* ============================================================== dispatcher */
|
||||
|
||||
const ROUTES = [
|
||||
[/\bdepartment|dept\b/i, answerByDept],
|
||||
[/\bposition|role\b/i, answerByPosition],
|
||||
[/\bhub|location|site|venue\b/i, answerByHub],
|
||||
[/\bmargin|leak|profit|overrun|waste\b/i, answerMargin],
|
||||
[/\bhours?|overtime|scheduled vs|utilisation|utilization\b/i, answerHours],
|
||||
[/\brevenue|billed|invoice value|income|spend\b/i, answerRevenue],
|
||||
[/\bchanged|this week|compared|trend|over time|growth\b/i, answerChanged],
|
||||
[/\battention|risk|anomal|unusual|watch|concern\b/i, answerAttention],
|
||||
[/\bvolume|how many|throughput|completed|count\b/i, answerVolume],
|
||||
[/\bsummar|overview|recap|status|how (are|is) things|break ?down\b/i, answerSummary],
|
||||
];
|
||||
|
||||
/**
|
||||
* What a pronoun means, given what Owliver just said — the same mechanism the
|
||||
* other packs use, so a follow-up asked here stays on this page's records.
|
||||
*/
|
||||
const FROM_TOPIC = {
|
||||
summary: [
|
||||
[/\bbiggest|most|largest|worst\b/i, answerByDept],
|
||||
[/\bchang|trend|over time\b/i, answerChanged],
|
||||
[/\brisk|attention\b/i, answerAttention],
|
||||
[/\bhours?\b/i, answerHours],
|
||||
],
|
||||
changed: [
|
||||
[/\bcaused|why|driver|where\b/i, answerMargin],
|
||||
[/\bbiggest|most|largest\b/i, answerByDept],
|
||||
],
|
||||
breakdown: [
|
||||
[/\bwhy|caused\b/i, answerMargin],
|
||||
[/\bhours?\b/i, answerHours],
|
||||
],
|
||||
margin: [
|
||||
[/\bbiggest|most|largest|worst\b/i, answerByDept],
|
||||
[/\bhours?\b/i, answerHours],
|
||||
],
|
||||
attention: [
|
||||
[/\bbiggest|most|worst|first\b/i, answerMargin],
|
||||
[/\bhours?\b/i, answerHours],
|
||||
],
|
||||
hours: [
|
||||
[/\bbiggest|most|largest\b/i, answerByPosition],
|
||||
[/\bcost|money|spend\b/i, answerMargin],
|
||||
],
|
||||
revenue: [
|
||||
[/\bbiggest|most|largest\b/i, answerByDept],
|
||||
[/\bchang|trend\b/i, answerChanged],
|
||||
],
|
||||
volume: [
|
||||
[/\bbiggest|most|largest\b/i, answerByDept],
|
||||
],
|
||||
};
|
||||
|
||||
const FOLLOW_UP = /\b(one|ones|these|those|them|it|that|this)\b/i;
|
||||
|
||||
const answerUnknown = (d) => ({
|
||||
topic: null,
|
||||
stages: ["Reading the numbers…"],
|
||||
blocks: blocks(
|
||||
text("I can analyse the metrics tracked in this workspace, but I couldn't map that question onto them."),
|
||||
actions([
|
||||
{ label: "Summary", prompt: "Give me a summary" },
|
||||
d.timeline.length > 1 ? { label: "Trends", prompt: "What changed over the period?" } : null,
|
||||
d.byDept.length ? { label: "Departments", prompt: "Which department costs the most?" } : null,
|
||||
{ label: "Margin", prompt: "Where is margin leaking?" },
|
||||
{ label: "Risks", prompt: "What in the numbers needs my attention?" },
|
||||
].filter(Boolean), { title: "Try asking about" })
|
||||
),
|
||||
followUps: [],
|
||||
});
|
||||
|
||||
/* ================================================================== export */
|
||||
|
||||
export const analytics = {
|
||||
key: "analytics",
|
||||
title: "Operational Intelligence",
|
||||
placeholder: "Ask Owliver about trends, metrics, or performance...",
|
||||
contextLabel: "Using Analytics data",
|
||||
working: "Reading the numbers",
|
||||
|
||||
contextFacts: (signals) => {
|
||||
const t = workspace(signals).totals;
|
||||
return [
|
||||
{ value: n(t.events), label: "orders tracked" },
|
||||
{ value: n(t.completed), label: "completed" },
|
||||
{ value: n(t.invoices), label: "invoices" },
|
||||
].filter((f) => f.value > 0);
|
||||
},
|
||||
|
||||
read: (signals) => {
|
||||
const d = workspace(signals);
|
||||
const t = d.totals;
|
||||
|
||||
if (!t.events && !t.invoices) {
|
||||
return {
|
||||
tone: "neutral",
|
||||
headline: "Nothing tracked in the selected period.",
|
||||
lines: ["Owliver reads the orders and invoices this page has loaded."],
|
||||
stat: null,
|
||||
};
|
||||
}
|
||||
|
||||
const lead = d.byDept[0] || d.byHub[0];
|
||||
return {
|
||||
tone: t.fillRatePct < 95 || (t.budget && t.budgetLeft < 0) ? "warning" : "neutral",
|
||||
headline: `${plural(t.completed, "order", "orders")} completed out of ${t.events} tracked.`,
|
||||
lines: [
|
||||
`**${money(t.spend)}** of labor spend across ${plural(t.invoices, "invoice", "invoices")} in the ${d.view.periodLabel}.`,
|
||||
lead
|
||||
? `**${lead.name}** is the largest cost centre at ${money(lead.spend)}.`
|
||||
: `**${pct(completionRate(t), 1)} completion rate** across the tracked window.`,
|
||||
t.openShifts ? `**${plural(t.openShifts, "shift is", "shifts are")}** still unfilled.` : null,
|
||||
].filter(Boolean),
|
||||
stat: { label: "Billed", value: money(t.spend, { compact: true }) },
|
||||
};
|
||||
},
|
||||
|
||||
/** Derived, so a chip is only offered when the page can answer it. */
|
||||
suggestions: (signals) => {
|
||||
const d = workspace(signals);
|
||||
const t = d.totals;
|
||||
const dept = d.byDept[0];
|
||||
|
||||
return [
|
||||
{ label: "Give me a summary", prompt: "Give me a summary" },
|
||||
d.timeline.length > 1 && { label: "What changed over the period?", prompt: "What changed over the period?" },
|
||||
dept && { label: `Why is ${dept.name} the biggest cost?`, prompt: "Which department costs the most?" },
|
||||
d.byPosition.length && { label: "Which position costs most?", prompt: "Which position costs the most?" },
|
||||
(t.openShifts > 0 || t.fillRatePct < 95) && { label: "What needs attention?", prompt: "What in the numbers needs my attention?" },
|
||||
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
|
||||
t.scheduledHours > 0 && { label: "Scheduled vs worked hours", prompt: "Show scheduled against worked hours" },
|
||||
].filter(Boolean);
|
||||
},
|
||||
|
||||
answer({ prompt, signals, lastTopic }) {
|
||||
const d = workspace(signals);
|
||||
const q = String(prompt || "");
|
||||
|
||||
if (FOLLOW_UP.test(q) && lastTopic && FROM_TOPIC[lastTopic]) {
|
||||
const hit = FROM_TOPIC[lastTopic].find(([pattern]) => pattern.test(q));
|
||||
if (hit) return hit[1](d);
|
||||
}
|
||||
|
||||
const route = ROUTES.find(([pattern]) => pattern.test(q));
|
||||
return route ? route[1](d) : answerUnknown(d);
|
||||
},
|
||||
};
|
||||
|
||||
export default analytics;
|
||||
216
src/lib/owliver/knowledge/analyticsWorkspace.js
Normal file
216
src/lib/owliver/knowledge/analyticsWorkspace.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Analytics — workspace derivation
|
||||
* --------------------------------
|
||||
* The Reports page's own records, read into the shape Owliver answers from.
|
||||
* Same contract as `savingsWorkspace.js`, and for the same reason:
|
||||
*
|
||||
* the page `ClientTrendsReport` publishes the figures and series it has
|
||||
* already computed to draw its own charts, together with the
|
||||
* period the reader has selected
|
||||
* the route `DOMAINS.analytics.loadSignals()` fetches Events and Invoices
|
||||
* and recomputes those same figures here
|
||||
*
|
||||
* Where the page hands over a figure, it is used as-is — the page computed it to
|
||||
* draw a chart, and recomputing it slightly differently is how a panel ends up
|
||||
* contradicting the page beside it. Only the route-level path derives.
|
||||
*/
|
||||
|
||||
const num = (v) => Number(v || 0);
|
||||
const round = (v) => Math.round(num(v));
|
||||
|
||||
const PERIOD_LABEL = {
|
||||
"7d": "last 7 days", "30d": "last 30 days", "90d": "last 90 days",
|
||||
ytd: "year to date", all: "all time",
|
||||
};
|
||||
|
||||
const asDate = (value) => {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
};
|
||||
|
||||
/* --------------------------------------------------------------- shift maths */
|
||||
|
||||
/** Scheduled hours on one order: hours × headcount, across every shift role. */
|
||||
const scheduledHoursOf = (event) =>
|
||||
(event.shifts || []).reduce(
|
||||
(s, sh) => s + (sh.roles || []).reduce((sr, r) => sr + num(r.hours) * num(r.count), 0), 0
|
||||
);
|
||||
|
||||
const requestedCountOf = (event) =>
|
||||
(event.shifts || []).reduce(
|
||||
(sc, sh) => sc + (sh.roles || []).reduce((sr, r) => sr + num(r.count), 0), 0
|
||||
);
|
||||
|
||||
/**
|
||||
* Worked hours, on the report's own model.
|
||||
*
|
||||
* Reproduced from `ClientTrendsReport` rather than reinvented — including its
|
||||
* per-order variance term, which is a deterministic function of the order name.
|
||||
* A different formula here would put a different number in the panel than the
|
||||
* chart shows, which is the one outcome worth going to this length to avoid.
|
||||
*/
|
||||
const workedHoursOf = (event, avgHours) => {
|
||||
const variance = 0.92 + (Math.abs(event.event_name?.charCodeAt(0) || 65) % 8) * 0.01;
|
||||
return num((event.assigned_staff || []).length) * avgHours * variance;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------ raw derivation */
|
||||
|
||||
function deriveFromRecords(events, invoices) {
|
||||
const revenue = invoices.reduce((s, i) => s + num(i.amount), 0);
|
||||
const orderValue = events.reduce((s, e) => s + num(e.total), 0);
|
||||
|
||||
const scheduledHours = events.reduce((s, e) => s + scheduledHoursOf(e), 0);
|
||||
const workedHours = events.reduce((s, e) => {
|
||||
const count = requestedCountOf(e);
|
||||
const avg = count > 0 ? scheduledHours / count : 5;
|
||||
return s + workedHoursOf(e, avg);
|
||||
}, 0);
|
||||
|
||||
const scheduledShifts = events.reduce((s, e) => s + num(e.requested), 0);
|
||||
const workedShifts = events.reduce((s, e) => s + (e.assigned_staff || []).length, 0);
|
||||
const savedHours = scheduledHours - workedHours;
|
||||
const avgRate = revenue > 0 && workedHours > 0 ? revenue / workedHours : 28;
|
||||
|
||||
return {
|
||||
spend: revenue,
|
||||
prevSpend: 0,
|
||||
spendDelta: null,
|
||||
budget: revenue > 0 ? Math.round(revenue * 1.148) : 0,
|
||||
scheduledHours,
|
||||
workedHours,
|
||||
savedHours,
|
||||
hoursSavings: savedHours * avgRate,
|
||||
fillRatePct: scheduledShifts > 0 ? (workedShifts / scheduledShifts) * 100 : 100,
|
||||
openShifts: Math.max(scheduledShifts - workedShifts, 0),
|
||||
overtimeHours: workedHours > scheduledHours ? workedHours - scheduledHours : 0,
|
||||
noShows: events.filter((e) => (e.assigned_staff || []).length < num(e.requested)).length,
|
||||
revenuePerLaborHour: workedHours > 0 ? orderValue / workedHours : 0,
|
||||
laborCostPct: orderValue > 0 ? (revenue / orderValue) * 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Monthly spend, from the dates the invoices and orders actually carry. */
|
||||
function deriveTimeline(events, invoices) {
|
||||
const buckets = new Map();
|
||||
const put = (date, amount) => {
|
||||
const d = asDate(date);
|
||||
if (!d) return;
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
const entry = buckets.get(key)
|
||||
|| { key, label: d.toLocaleDateString("en-US", { month: "short" }), value: 0 };
|
||||
entry.value += num(amount);
|
||||
buckets.set(key, entry);
|
||||
};
|
||||
|
||||
for (const i of invoices) put(i.issue_date, i.amount);
|
||||
if (!buckets.size) for (const e of events) put(e.date, e.total);
|
||||
|
||||
return [...buckets.values()]
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
.slice(-6)
|
||||
.map(({ label, value }) => ({ label, value: round(value) }));
|
||||
}
|
||||
|
||||
const groupSpend = (events, pick) => {
|
||||
const map = new Map();
|
||||
for (const e of events) {
|
||||
const key = pick(e) || "Unknown";
|
||||
const entry = map.get(key) || { name: key, spend: 0, count: 0 };
|
||||
entry.spend += num(e.total);
|
||||
entry.count += (e.assigned_staff || []).length;
|
||||
map.set(key, entry);
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.spend - a.spend).slice(0, 8);
|
||||
};
|
||||
|
||||
const groupRoles = (events, pick) => {
|
||||
const map = new Map();
|
||||
for (const e of events) {
|
||||
for (const sh of e.shifts || []) {
|
||||
for (const r of sh.roles || []) {
|
||||
const key = pick(r) || "Unknown";
|
||||
const entry = map.get(key) || { name: key, spend: 0, count: 0 };
|
||||
entry.spend += num(r.total_value);
|
||||
entry.count += num(r.count);
|
||||
map.set(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.spend - a.spend).slice(0, 8);
|
||||
};
|
||||
|
||||
/* ============================================================ the workspace */
|
||||
|
||||
/**
|
||||
* The Analytics page, as the object Owliver answers from.
|
||||
*
|
||||
* `metrics` and the series are taken straight from the page when it publishes
|
||||
* them; everything else falls back to a derivation over the raw records.
|
||||
*/
|
||||
export function buildAnalyticsWorkspace(page = {}) {
|
||||
const {
|
||||
events = [], invoices = [], period = "all", groupBy = "monthly",
|
||||
activeTab = "executive", userRole = "admin",
|
||||
} = page || {};
|
||||
|
||||
const derived = page.metrics ? null : deriveFromRecords(events, invoices);
|
||||
const m = page.metrics || derived;
|
||||
|
||||
const completed = events.filter((e) => e.status === "Completed").length;
|
||||
const timeline = (page.spendTimeline || []).length
|
||||
? page.spendTimeline.map((row) => ({
|
||||
label: row.period, value: round(row.thisPeriod), hours: round(row.hours),
|
||||
}))
|
||||
: deriveTimeline(events, invoices);
|
||||
|
||||
const byHub = (page.byHub || []).length ? page.byHub : groupSpend(events, (e) => e.hub || "No Hub");
|
||||
const byDept = (page.byDept || []).length ? page.byDept : groupRoles(events, (r) => r.department);
|
||||
const byPosition = (page.byPosition || []).length ? page.byPosition : groupRoles(events, (r) => r.role);
|
||||
|
||||
return {
|
||||
view: {
|
||||
period, groupBy, activeTab, userRole,
|
||||
periodLabel: PERIOD_LABEL[period] || String(period),
|
||||
},
|
||||
|
||||
totals: {
|
||||
/* Volume — what the page is reading. */
|
||||
events: events.length,
|
||||
completed,
|
||||
open: events.length - completed,
|
||||
invoices: invoices.length,
|
||||
|
||||
/* Money. */
|
||||
revenue: round(m.totalSpend ?? m.spend),
|
||||
spend: round(m.totalSpend ?? m.spend),
|
||||
prevSpend: round(m.prevSpend),
|
||||
spendDelta: m.spendDelta == null ? null : Number(m.spendDelta),
|
||||
budget: round(m.budget),
|
||||
budgetUsedPct: num(m.budgetUsedPct),
|
||||
budgetLeft: round(m.budgetLeft ?? num(m.budget) - num(m.totalSpend ?? m.spend)),
|
||||
laborCostPct: num(m.laborCostPct),
|
||||
revenuePerLaborHour: num(m.revenuePerLaborHour),
|
||||
|
||||
/* Hours and coverage. */
|
||||
scheduledHours: round(m.scheduledHours),
|
||||
workedHours: round(m.effectiveWorkedHours ?? m.workedHours),
|
||||
savedHours: round(m.savedHours),
|
||||
hoursSavings: round(m.hoursSavings),
|
||||
overtimeHours: round(m.overtimeHours),
|
||||
fillRatePct: num(m.fillRatePct),
|
||||
openShifts: round(m.openShifts),
|
||||
noShows: round(m.noShows),
|
||||
lateArrivals: round(m.lateArrivals),
|
||||
},
|
||||
|
||||
timeline,
|
||||
hoursTimeline: page.hoursTimeline || [],
|
||||
byHub,
|
||||
byDept,
|
||||
byPosition,
|
||||
};
|
||||
}
|
||||
|
||||
export default buildAnalyticsWorkspace;
|
||||
115
src/lib/owliver/knowledge/fallback.js
Normal file
115
src/lib/owliver/knowledge/fallback.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Fallback knowledge — every page without a dedicated pack
|
||||
* --------------------------------------------------------
|
||||
* Owliver covers ~80 routes. Only the pages with a real analytical story get a
|
||||
* hand-written pack; the rest keep the behaviour they already had — page-derived
|
||||
* suggestions from the registry and answers from the local engine — and this
|
||||
* adapter lifts that markdown into the same block schema the renderer uses.
|
||||
*
|
||||
* The adapter never invents structure: a lead sentence carrying a number becomes
|
||||
* a metric, order lines become cards, links become actions, and anything it does
|
||||
* not recognise stays as text. Degrading to prose is always safe.
|
||||
*/
|
||||
|
||||
import { blocks, text, metric, cards, actions, status, plural } from "../blocks";
|
||||
import { buildAnswerModel } from "../answerModel";
|
||||
|
||||
const SEVERITY = (card) => {
|
||||
if (!card.requested) return { badge: "Unsized", tone: "neutral" };
|
||||
const filled = card.assigned / card.requested;
|
||||
if (filled >= 1) return { badge: "On track", tone: "positive" };
|
||||
if (filled < 0.5) return { badge: "High risk", tone: "risk" };
|
||||
return { badge: "Medium risk", tone: "warning" };
|
||||
};
|
||||
|
||||
const toCard = (c) => {
|
||||
const sev = SEVERITY(c);
|
||||
return {
|
||||
id: c.id,
|
||||
title: c.business || c.name || "Record",
|
||||
subtitle: c.name && c.name !== c.business ? c.name : null,
|
||||
value: c.requested ? `${c.assigned}/${c.requested}` : null,
|
||||
valueLabel: c.requested ? "staffed" : null,
|
||||
badge: sev.badge,
|
||||
badgeTone: sev.tone,
|
||||
meta: [
|
||||
c.hub && { label: "Hub", value: c.hub },
|
||||
c.date && { label: "Date", value: c.time ? `${c.date} · ${c.time}` : c.date },
|
||||
].filter(Boolean),
|
||||
progress: c.requested
|
||||
? { value: c.assigned, max: c.requested, label: c.gap > 0 ? `${plural(c.gap, "seat", "seats")} still open` : "Fully staffed", tone: sev.tone }
|
||||
: null,
|
||||
body: !c.requested && c.statusText ? c.statusText : null,
|
||||
route: c.id ? `/EventDetail?id=${c.id}` : null,
|
||||
actionLabel: c.id ? "View record" : null,
|
||||
};
|
||||
};
|
||||
|
||||
/** Legacy markdown answer → blocks. */
|
||||
export function markdownToBlocks(markdown) {
|
||||
const model = buildAnswerModel(markdown);
|
||||
const { insight, cards: recs, paragraphs, actions: acts } = model;
|
||||
|
||||
const lead = [];
|
||||
if (insight?.tone === "positive") {
|
||||
lead.push(status({ tone: "positive", label: insight.label, text: insight.text }));
|
||||
} else if (insight && insight.tone !== "prose" && insight.count != null) {
|
||||
lead.push(
|
||||
metric({
|
||||
label: insight.label,
|
||||
value: String(insight.count),
|
||||
caption: insight.unit,
|
||||
sub: insight.secondary ? [{ label: insight.secondary.unit, value: String(insight.secondary.value) }] : [],
|
||||
}),
|
||||
text(insight.text)
|
||||
);
|
||||
} else if (insight?.text) {
|
||||
lead.push(text(insight.text));
|
||||
}
|
||||
|
||||
return blocks(
|
||||
lead,
|
||||
recs.length ? cards(recs.map(toCard)) : null,
|
||||
paragraphs.length ? text(paragraphs.join("\n\n")) : null,
|
||||
acts.length ? actions(acts.map((a) => ({ label: a.label, route: a.route }))) : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a pack for a route-derived domain. `domain` is the registry entry, so
|
||||
* the title, placeholder, working line, insight and suggestions all stay the
|
||||
* page's own.
|
||||
*/
|
||||
export const fallbackPack = (domain) => ({
|
||||
key: domain.key,
|
||||
title: domain.title,
|
||||
placeholder: domain.placeholder || `Ask Owliver about ${String(domain.title || "this page").toLowerCase()}...`,
|
||||
contextLabel: `Using ${domain.title} data`,
|
||||
working: (domain.working || "Owliver is analyzing").replace(/[….]$/, ""),
|
||||
|
||||
contextFacts: (d) => {
|
||||
if (!d) return [];
|
||||
const MEASURE = /cost|amount|spend|rate|percent|pct|score|hours|avg|average|revenue/i;
|
||||
return Object.entries(d)
|
||||
.filter(([k, v]) => typeof v === "number" && Number.isInteger(v) && v > 0 && !MEASURE.test(k))
|
||||
.slice(0, 3)
|
||||
.map(([k, v]) => ({ value: v, label: k.replace(/([A-Z])/g, " $1").replace(/_/g, " ").toLowerCase().trim() }));
|
||||
},
|
||||
|
||||
read: (d) => {
|
||||
const insight = d ? domain.buildInsight(d) : null;
|
||||
if (!insight?.headline) return null;
|
||||
return { tone: "neutral", headline: insight.headline, lines: insight.supporting ? [insight.supporting] : [] };
|
||||
},
|
||||
|
||||
suggestions: (d) => (d ? (domain.buildSuggestions(d) || []).filter(Boolean) : []),
|
||||
|
||||
/** Async: the local engine is loaded on demand, exactly as it was before. */
|
||||
async answer({ prompt, pageContext, conversation }) {
|
||||
const { getOwliverResponse } = await import("../owliverEngine");
|
||||
const markdown = await getOwliverResponse({ prompt, pageContext, conversation, skipLatency: true });
|
||||
return { topic: null, stages: [], blocks: markdownToBlocks(markdown), followUps: [] };
|
||||
},
|
||||
});
|
||||
|
||||
export default fallbackPack;
|
||||
45
src/lib/owliver/knowledge/index.js
Normal file
45
src/lib/owliver/knowledge/index.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Page-aware knowledge registry
|
||||
* -----------------------------
|
||||
*
|
||||
* Active page → domain key → knowledge pack → suggestions / read / answers
|
||||
*
|
||||
* A pack is the whole contract between a page and Owliver:
|
||||
*
|
||||
* title, placeholder, contextLabel, working
|
||||
* contextFacts(signals) → [{ value, label, tone }]
|
||||
* read(signals) → { tone, headline, lines[], stat } (the current read)
|
||||
* suggestions(signals) → [{ label, prompt }]
|
||||
* answer({ prompt, signals, conversation, lastTopic, pageContext })
|
||||
* → { topic, stages[], blocks[], followUps[] }
|
||||
*
|
||||
* Packs are keyed by the domain key the page registry already resolves, so a
|
||||
* page gets its pack simply by being on a route that maps to that domain.
|
||||
* Anything without a pack falls back to the route's registry entry, which is
|
||||
* still page-specific — there is no shared default suggestion list anywhere.
|
||||
*/
|
||||
|
||||
import savingEngine from "./savingEngine";
|
||||
import orders from "./orders";
|
||||
import analytics from "./analytics";
|
||||
import { fallbackPack } from "./fallback";
|
||||
|
||||
export const KNOWLEDGE_PACKS = {
|
||||
savingEngine,
|
||||
orders,
|
||||
analytics,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} domainKey resolved domain for the active page
|
||||
* @param {object} domain the registry entry, used to build a fallback
|
||||
*/
|
||||
export function getKnowledge(domainKey, domain) {
|
||||
const pack = KNOWLEDGE_PACKS[domainKey];
|
||||
if (pack) return pack;
|
||||
return fallbackPack(domain || { key: domainKey, title: "this workspace", buildInsight: () => null, buildSuggestions: () => [] });
|
||||
}
|
||||
|
||||
export const hasPack = (domainKey) => !!KNOWLEDGE_PACKS[domainKey];
|
||||
|
||||
export default getKnowledge;
|
||||
369
src/lib/owliver/knowledge/orders.js
Normal file
369
src/lib/owliver/knowledge/orders.js
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Order Management — page knowledge
|
||||
* ---------------------------------
|
||||
* Unlike the Saving Engine pack, nothing here is demo data: `signals` is the
|
||||
* live summary Events.jsx registers through useOwliverPageContext, normalised
|
||||
* by PAGE_CONTEXTS.orders. The pack's job is only to turn that summary into
|
||||
* blocks — which is exactly the job it will do when the Saving Engine's demo
|
||||
* payload is replaced by a real one.
|
||||
*/
|
||||
|
||||
import {
|
||||
blocks, text, metric, cards, table, actions, list, status, plural,
|
||||
} from "../blocks";
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/**
|
||||
* "08.26.2026" → "Aug 26". The registry emits the dotted form for the engine's
|
||||
* markdown lines; in a card the year is noise — these are operational dates.
|
||||
*/
|
||||
const shortDate = (raw) => {
|
||||
const m = String(raw || "").match(/^(\d{1,2})\.(\d{1,2})\.\d{4}$/);
|
||||
if (!m) return raw || null;
|
||||
const month = MONTHS[Number(m[1]) - 1];
|
||||
return month ? `${month} ${Number(m[2])}` : raw;
|
||||
};
|
||||
|
||||
const SEVERITY = (card) => {
|
||||
if (!card.requested) return { badge: "Unsized", tone: "neutral" };
|
||||
const filled = card.assigned / card.requested;
|
||||
if (filled >= 1) return { badge: "On track", tone: "positive" };
|
||||
if (filled < 0.5) return { badge: "High risk", tone: "risk" };
|
||||
return { badge: "Medium risk", tone: "warning" };
|
||||
};
|
||||
|
||||
/** An order record → a panel card. */
|
||||
const orderCard = (o) => {
|
||||
const sev = SEVERITY(o);
|
||||
return {
|
||||
id: o.id,
|
||||
title: o.business || o.name || "Order",
|
||||
subtitle: o.name && o.name !== o.business ? o.name : null,
|
||||
value: o.requested ? `${o.assigned}/${o.requested}` : null,
|
||||
valueLabel: o.requested ? "staffed" : null,
|
||||
badge: sev.badge,
|
||||
badgeTone: sev.tone,
|
||||
meta: [
|
||||
o.hub && { label: "Hub", value: o.hub },
|
||||
o.date && { label: "Date", value: shortDate(o.date) },
|
||||
].filter(Boolean),
|
||||
progress: o.requested
|
||||
? {
|
||||
value: o.assigned,
|
||||
max: o.requested,
|
||||
label: o.gap > 0 ? `${plural(o.gap, "seat", "seats")} still open` : "Fully staffed",
|
||||
tone: sev.tone,
|
||||
}
|
||||
: null,
|
||||
route: o.id ? `/EventDetail?id=${o.id}` : "/Events",
|
||||
actionLabel: "View order",
|
||||
};
|
||||
};
|
||||
|
||||
const listOf = (d, key) => (Array.isArray(d[key]) ? d[key] : []);
|
||||
|
||||
/* ---------------------------------------------------------------- answers */
|
||||
|
||||
const answerAttention = (d) => {
|
||||
const gaps = listOf(d, "gapsOrders").slice(0, 3);
|
||||
if (!gaps.length) {
|
||||
return {
|
||||
topic: "attention",
|
||||
stages: [`Reading ${d.total} orders…`],
|
||||
blocks: blocks(
|
||||
status({ tone: "positive", label: "No staffing gaps", text: "Every open order on this page is fully staffed." })
|
||||
),
|
||||
followUps: [
|
||||
{ label: "What is still pending?", prompt: "Which orders are still pending confirmation?" },
|
||||
{ label: "Show today's critical orders", prompt: "Show today's critical orders" },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
topic: "attention",
|
||||
stages: [`Reading ${d.total} orders…`, "Ranking by open headcount…"],
|
||||
blocks: blocks(
|
||||
text(`**${plural(gaps.length, "order needs", "orders need")} staffing attention**, ranked by how short they are.`),
|
||||
metric({
|
||||
label: "Open headcount",
|
||||
value: String(d.openSeats),
|
||||
caption: `seats open across ${plural(d.total, "order", "orders")}`,
|
||||
sub: [
|
||||
{ label: "Partially staffed", value: String(d.partial) },
|
||||
{ label: "Upcoming at risk", value: String(d.atRisk) },
|
||||
],
|
||||
}),
|
||||
cards(gaps.map(orderCard)),
|
||||
actions([
|
||||
{ label: "Open Order Management", route: "/Events" },
|
||||
{ label: "Show upcoming risks", prompt: "Show upcoming risks" },
|
||||
])
|
||||
),
|
||||
followUps: [
|
||||
{ label: "Which one needs people first?", prompt: "Which one needs people first?" },
|
||||
{ label: "Show upcoming risks", prompt: "Show upcoming risks" },
|
||||
{ label: "What is still pending?", prompt: "Which orders are still pending confirmation?" },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const answerRisk = (d) => {
|
||||
const atRisk = listOf(d, "atRiskOrders").slice(0, 3);
|
||||
if (!atRisk.length) {
|
||||
return {
|
||||
topic: "risk",
|
||||
stages: ["Checking upcoming orders…"],
|
||||
blocks: blocks(
|
||||
status({ tone: "positive", label: "No immediate risk", text: "Nothing upcoming is short of headcount." })
|
||||
),
|
||||
followUps: [{ label: "Which orders need staffing?", prompt: "Which orders need staffing attention?" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
topic: "risk",
|
||||
stages: ["Checking upcoming orders…"],
|
||||
blocks: blocks(
|
||||
text(`**${plural(d.atRisk, "upcoming order is", "upcoming orders are")}** not fully staffed yet.`),
|
||||
cards(atRisk.map(orderCard)),
|
||||
actions([{ label: "Open Order Management", route: "/Events" }])
|
||||
),
|
||||
followUps: [
|
||||
{ label: "Which one needs people first?", prompt: "Which one needs people first?" },
|
||||
{ label: "Which orders need staffing?", prompt: "Which orders need staffing attention?" },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const answerFirst = (d) => {
|
||||
const first = d.neediest || d.soonestAtRisk || listOf(d, "gapsOrders")[0];
|
||||
if (!first) return null;
|
||||
return {
|
||||
topic: "first",
|
||||
stages: ["Ranking by urgency…"],
|
||||
blocks: blocks(
|
||||
text(`**${first.business}** needs people first — ${plural(first.gap, "seat", "seats")} open, ${shortDate(first.date) || "date unset"}.`),
|
||||
cards([orderCard(first)]),
|
||||
actions([{ label: "Open this order", route: first.id ? `/EventDetail?id=${first.id}` : "/Events" }])
|
||||
),
|
||||
followUps: [
|
||||
{ label: "Show partially staffed orders", prompt: "Show partially staffed orders" },
|
||||
{ label: "Show upcoming risks", prompt: "Show upcoming risks" },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const answerPartial = (d) => {
|
||||
const partial = listOf(d, "partialOrders");
|
||||
if (!partial.length) {
|
||||
return {
|
||||
topic: "partial",
|
||||
stages: ["Checking fill rates…"],
|
||||
blocks: blocks(status({ tone: "positive", label: "No partial orders", text: "Nothing on this page is part-filled." })),
|
||||
followUps: [{ label: "What is still pending?", prompt: "Which orders are still pending confirmation?" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
topic: "partial",
|
||||
stages: ["Checking fill rates…"],
|
||||
blocks: blocks(
|
||||
text(`${plural(partial.length, "order is", "orders are")} partially staffed.`),
|
||||
table({
|
||||
title: "Partially staffed",
|
||||
columns: [
|
||||
{ key: "business", label: "Order", align: "left" },
|
||||
{ key: "gap", label: "Open", align: "right", sortable: true, numeric: true },
|
||||
{ key: "date", label: "Date", align: "left" },
|
||||
],
|
||||
rows: partial.slice(0, 8).map((o) => ({
|
||||
id: o.id,
|
||||
business: o.business,
|
||||
gap: o.gap,
|
||||
gapDisplay: String(o.gap),
|
||||
date: shortDate(o.date) || "—",
|
||||
route: o.id ? `/EventDetail?id=${o.id}` : "/Events",
|
||||
detail: `${o.assigned} of ${o.requested} staffed${o.hub ? ` · ${o.hub}` : ""}`,
|
||||
})),
|
||||
footnote: "Tap a row for detail",
|
||||
})
|
||||
),
|
||||
followUps: [
|
||||
{ label: "Which one needs people first?", prompt: "Which one needs people first?" },
|
||||
{ label: "Show upcoming risks", prompt: "Show upcoming risks" },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const answerPending = (d) => {
|
||||
const pending = listOf(d, "pendingOrders");
|
||||
return {
|
||||
topic: "pending",
|
||||
stages: ["Checking confirmations…"],
|
||||
blocks: pending.length
|
||||
? blocks(
|
||||
text(`${plural(pending.length, "order is", "orders are")} still pending confirmation.`),
|
||||
cards(pending.slice(0, 3).map(orderCard))
|
||||
)
|
||||
: blocks(status({ tone: "positive", label: "Nothing pending", text: "Every order on this page is confirmed." })),
|
||||
followUps: [
|
||||
{ label: "Which orders need staffing?", prompt: "Which orders need staffing attention?" },
|
||||
{ label: "Show upcoming risks", prompt: "Show upcoming risks" },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const answerToday = (d) => {
|
||||
const upcoming = listOf(d, "upcomingOrders").slice(0, 3);
|
||||
return {
|
||||
topic: "today",
|
||||
stages: ["Reading the next few days…"],
|
||||
blocks: upcoming.length
|
||||
? blocks(text(`The ${upcoming.length} soonest orders on this page:`), cards(upcoming.map(orderCard)))
|
||||
: blocks(status({ tone: "neutral", label: "Nothing upcoming", text: "No future-dated orders on this page." })),
|
||||
followUps: [
|
||||
{ label: "Which orders need staffing?", prompt: "Which orders need staffing attention?" },
|
||||
{ label: "What is still pending?", prompt: "Which orders are still pending confirmation?" },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const answerSummary = (d) => ({
|
||||
topic: "summary",
|
||||
stages: [`Reading ${d.total} orders…`],
|
||||
blocks: blocks(
|
||||
text(`${plural(d.total, "order", "orders")} on this page — ${d.upcoming} upcoming, ${d.active} active, ${d.draft} in draft.`),
|
||||
metric({
|
||||
label: "Staffed",
|
||||
value: `${d.totalAssignedSeats}/${d.totalRequestedSeats}`,
|
||||
caption: "seats filled across open orders",
|
||||
sub: [{ label: "Open seats", value: String(d.openSeats) }, { label: "At risk", value: String(d.atRisk) }],
|
||||
}),
|
||||
list({
|
||||
title: "Breakdown",
|
||||
items: [
|
||||
{ label: "Fully staffed", value: String(d.full), tone: "positive" },
|
||||
{ label: "Partially staffed", value: String(d.partial), tone: d.partial ? "risk" : "neutral" },
|
||||
{ label: "Pending confirmation", value: String(d.requested) },
|
||||
{ label: "Drafts", value: String(d.draft) },
|
||||
],
|
||||
}),
|
||||
actions([{ label: "Open Order Management", route: "/Events" }])
|
||||
),
|
||||
followUps: [
|
||||
{ label: "Which orders need staffing?", prompt: "Which orders need staffing attention?" },
|
||||
{ label: "Show upcoming risks", prompt: "Show upcoming risks" },
|
||||
],
|
||||
});
|
||||
|
||||
const ROUTES = [
|
||||
[/\bfirst|most urgent|top priority|needs people first\b/i, answerFirst],
|
||||
[/\bpartial|part.?filled|not fully staffed\b/i, answerPartial],
|
||||
[/\bpending|unconfirmed|awaiting\b/i, answerPending],
|
||||
[/\btoday|critical|next few days|soonest|upcoming\b/i, answerToday],
|
||||
[/\brisk|at risk|unstaffed|going wrong\b/i, answerRisk],
|
||||
[/\bstaffing|coverage|short|gap|headcount|attention|need\b/i, answerAttention],
|
||||
[/\bsummar|overview|recap|how (are|is) things|status\b/i, answerSummary],
|
||||
];
|
||||
|
||||
/**
|
||||
* What a pronoun means, given what Owliver just said.
|
||||
*
|
||||
* The whole of conversational context on this page: "which one is the biggest?"
|
||||
* after a summary is a question about the orders that summary counted, so it
|
||||
* resolves inside the same page and the same records rather than starting over
|
||||
* as a new question.
|
||||
*/
|
||||
const FROM_TOPIC = {
|
||||
summary: [
|
||||
[/\bbiggest|worst|first|most\b/i, answerFirst],
|
||||
[/\brisk|at risk\b/i, answerRisk],
|
||||
[/\bstaffing|short|gap|attention\b/i, answerAttention],
|
||||
[/\bpending|unconfirmed\b/i, answerPending],
|
||||
],
|
||||
attention: [[/\bfirst|which one|worst|biggest\b/i, answerFirst], [/\brisk\b/i, answerRisk]],
|
||||
risk: [[/\bfirst|which one|worst|biggest\b/i, answerFirst]],
|
||||
partial: [[/\bfirst|which one|biggest\b/i, answerFirst]],
|
||||
first: [[/\bothers?|rest|else|more\b/i, answerAttention], [/\brisk\b/i, answerRisk]],
|
||||
pending: [[/\bfirst|which one|biggest\b/i, answerFirst]],
|
||||
today: [[/\bfirst|which one|worst|biggest\b/i, answerFirst], [/\brisk\b/i, answerRisk]],
|
||||
};
|
||||
|
||||
const answerUnknown = (d) => ({
|
||||
topic: null,
|
||||
stages: ["Reading this page…"],
|
||||
blocks: blocks(
|
||||
text(`I can analyse the ${plural(d.total, "order", "orders")} in this workspace, but I couldn't map that question onto them.`),
|
||||
actions([
|
||||
{ label: "Staffing", prompt: "Which orders need staffing attention?" },
|
||||
{ label: "Risks", prompt: "Show upcoming risks" },
|
||||
{ label: "Pending", prompt: "Which orders are still pending confirmation?" },
|
||||
{ label: "Summary", prompt: "Summarise these orders" },
|
||||
], { title: "Try asking about" })
|
||||
),
|
||||
followUps: [],
|
||||
});
|
||||
|
||||
export const orders = {
|
||||
key: "orders",
|
||||
title: "Order Management",
|
||||
placeholder: "Ask Owliver about orders, staffing, or risks...",
|
||||
contextLabel: "Using Order Management data",
|
||||
working: "Reading your orders",
|
||||
|
||||
contextFacts: (d) => [
|
||||
{ value: d.total, label: d.total === 1 ? "order" : "orders" },
|
||||
{ value: d.upcoming, label: "upcoming" },
|
||||
{ value: d.partial, label: d.partial === 1 ? "staffing risk" : "staffing risks", tone: "risk" },
|
||||
].filter((f) => Number(f.value) > 0),
|
||||
|
||||
read: (d) => {
|
||||
if (d.partial > 0 || d.atRisk > 0) {
|
||||
return {
|
||||
tone: "risk",
|
||||
headline: `${plural(Math.max(d.atRisk, d.partial), "order needs", "orders need")} staffing attention.`,
|
||||
lines: [
|
||||
`**${plural(d.openSeats, "open seat", "open seats")}** remain across the highest-risk orders.`,
|
||||
`Reading ${plural(d.total, "order", "orders")} on this page — ${d.upcoming} upcoming, ${d.draft} in draft.`,
|
||||
],
|
||||
stat: { label: "Staffed", value: `${d.totalAssignedSeats}/${d.totalRequestedSeats}` },
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: "positive",
|
||||
headline: "All orders are staffed and on track.",
|
||||
lines: [`Reading ${plural(d.total, "order", "orders")} on this page — ${d.upcoming} upcoming, ${d.draft} in draft.`],
|
||||
stat: { label: "Staffed", value: `${d.totalAssignedSeats}/${d.totalRequestedSeats}` },
|
||||
};
|
||||
},
|
||||
|
||||
suggestions: (d) => [
|
||||
(d.partial > 0 || d.openSeats > 0) && { label: "Which orders need staffing?", prompt: "Which orders need staffing attention?" },
|
||||
d.atRisk > 0 && { label: "Show upcoming risks", prompt: "Show upcoming risks" },
|
||||
d.requested > 0 && { label: "What is still pending?", prompt: "Which orders are still pending confirmation?" },
|
||||
d.partial > 0 && { label: "Show partially staffed orders", prompt: "Show partially staffed orders" },
|
||||
{ label: "Show today's critical orders", prompt: "Show today's critical orders" },
|
||||
{ label: "Summarise these orders", prompt: "Summarise these orders" },
|
||||
].filter(Boolean).slice(0, 5),
|
||||
|
||||
answer({ prompt, signals, lastTopic }) {
|
||||
const d = signals || {};
|
||||
const q = String(prompt || "");
|
||||
|
||||
if (/\b(one|ones|these|those|them|it|that|this)\b/i.test(q) && lastTopic && FROM_TOPIC[lastTopic]) {
|
||||
const hit = FROM_TOPIC[lastTopic].find(([pattern]) => pattern.test(q));
|
||||
if (hit) {
|
||||
const answered = hit[1](d);
|
||||
if (answered) return answered;
|
||||
}
|
||||
}
|
||||
|
||||
const route = ROUTES.find(([pattern]) => pattern.test(q));
|
||||
if (route) {
|
||||
const answered = route[1](d);
|
||||
if (answered) return answered;
|
||||
}
|
||||
return answerUnknown(d);
|
||||
},
|
||||
};
|
||||
|
||||
export default orders;
|
||||
881
src/lib/owliver/knowledge/savingEngine.js
Normal file
881
src/lib/owliver/knowledge/savingEngine.js
Normal file
@@ -0,0 +1,881 @@
|
||||
/**
|
||||
* Saving Engine — page knowledge
|
||||
* ------------------------------
|
||||
* What Owliver knows about the Saving Engine workspace: the read it produces,
|
||||
* the questions that data makes worth asking, and the answers to them.
|
||||
*
|
||||
* Every answer below is a function of `signals` — the workspace the page
|
||||
* published, derived in `savingsWorkspace.js` from the assignments, vendors,
|
||||
* workforce, orders and rates the page had already loaded. Nothing in this file
|
||||
* holds a figure of its own, and nothing reads a fixture: if the page's records
|
||||
* change, every sentence here changes with them.
|
||||
*
|
||||
* That is also why each answer starts by checking whether the workspace has a
|
||||
* basis for it. A page with no opportunities detected says so; it does not fall
|
||||
* back to a number that was true of some other workspace.
|
||||
*/
|
||||
|
||||
import {
|
||||
blocks, text, note, metric, cards, table, chart, flow, actions, list,
|
||||
progress, status, timeline, compare, money, pct, plural,
|
||||
} from "../blocks";
|
||||
|
||||
/* ================================================================= helpers */
|
||||
|
||||
const IMPACT_TONE = { high: "risk", medium: "warning", low: "neutral" };
|
||||
const IMPACT_LABEL = { high: "High impact", medium: "Medium impact", low: "Low impact" };
|
||||
const SEVERITY_TONE = { high: "risk", medium: "warning", low: "neutral" };
|
||||
|
||||
/** An empty workspace still has the shape of one, so nothing has to null-check. */
|
||||
const EMPTY = {
|
||||
view: { activeTab: "overview", timeRange: "30days", timeLabel: "30-day", userRole: "admin" },
|
||||
totals: {},
|
||||
opportunities: [], risks: [], trend: [], byCategory: [], recentActivity: [], pipeline: [],
|
||||
};
|
||||
|
||||
const workspace = (signals) => (signals && signals.totals ? signals : EMPTY);
|
||||
|
||||
/**
|
||||
* Period-over-period movement, or null.
|
||||
*
|
||||
* Null when the dated records do not cover a prior period. Every caller checks,
|
||||
* because "↑ 0.0%" against nothing is a claim the page cannot support.
|
||||
*/
|
||||
const trendPct = (d) => {
|
||||
const { totalSavings, previousPeriodSavings } = d.totals;
|
||||
if (!previousPeriodSavings) return null;
|
||||
return ((Number(totalSavings || 0) - previousPeriodSavings) / previousPeriodSavings) * 100;
|
||||
};
|
||||
|
||||
/** The headline metric, assembled from whatever the workspace can support. */
|
||||
const savingsMetric = (d, label = "Savings potential") => {
|
||||
const movement = trendPct(d);
|
||||
return metric({
|
||||
label,
|
||||
value: money(d.totals.totalSavings),
|
||||
trend: movement == null ? undefined : `${movement >= 0 ? "↑" : "↓"} ${pct(Math.abs(movement), 1)}`,
|
||||
trendTone: movement == null ? "neutral" : movement >= 0 ? "positive" : "risk",
|
||||
caption: movement == null
|
||||
? `${d.view.timeLabel} projection`
|
||||
: `${d.view.timeLabel} projection · vs previous month`,
|
||||
sub: [
|
||||
{ label: "Monthly", value: money(d.totals.monthlyPotential) },
|
||||
{ label: "Annualized", value: money(d.totals.annualisedPotential) },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/** An opportunity record → a card the panel can render and open. */
|
||||
const oppCard = (o) => ({
|
||||
id: o.id,
|
||||
title: o.title,
|
||||
subtitle: o.category,
|
||||
value: money(o.annual),
|
||||
valueLabel: "per year",
|
||||
badge: IMPACT_LABEL[o.impact],
|
||||
badgeTone: IMPACT_TONE[o.impact],
|
||||
meta: [
|
||||
{ label: "Status", value: o.status },
|
||||
{ label: "Effort", value: o.effort },
|
||||
{ label: "Owner", value: o.owner },
|
||||
],
|
||||
progress: { value: o.share, max: 100, label: `${o.share}% of total opportunity` },
|
||||
route: o.route,
|
||||
actionLabel: "View",
|
||||
});
|
||||
|
||||
const riskCard = (r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
subtitle: r.dueInDays > 0 ? `Due in ${plural(r.dueInDays, "day", "days")}` : "Live now",
|
||||
value: money(r.exposure),
|
||||
valueLabel: "at risk",
|
||||
badge: r.severity === "high" ? "High severity" : "Medium severity",
|
||||
badgeTone: SEVERITY_TONE[r.severity],
|
||||
meta: [{ label: "Exposure", value: `${money(r.exposure)}/yr` }],
|
||||
body: r.detail,
|
||||
route: r.route,
|
||||
actionLabel: r.action,
|
||||
});
|
||||
|
||||
const topOpportunities = (d, n = 3) => d.opportunities.slice(0, n);
|
||||
|
||||
/**
|
||||
* The reply when the workspace cannot support a question at all.
|
||||
*
|
||||
* Says which records are missing rather than reporting a zero, because an empty
|
||||
* Saving Engine and a Saving Engine with nothing to save look identical in a
|
||||
* figure and are completely different things.
|
||||
*/
|
||||
const nothingToRead = (topic, what) => ({
|
||||
topic,
|
||||
stages: ["Reading Saving Engine data…"],
|
||||
blocks: blocks(
|
||||
text(`This workspace has no ${what} to read yet.`),
|
||||
note("Owliver reads the assignments, vendors, rates and workforce records on the Saving Engine page. Once those carry data, this answer fills in on its own."),
|
||||
actions([{ label: "Open Saving Engine", route: "/SavingsEngine" }])
|
||||
),
|
||||
followUps: [],
|
||||
});
|
||||
|
||||
/* ================================================================ answers */
|
||||
/**
|
||||
* Each responder returns the blocks for one answer plus the follow-ups that
|
||||
* answer makes sensible. Follow-ups are prompts — clicking one goes through the
|
||||
* same `send()` path as typing it, so nothing here is a canned branch — and
|
||||
* they are built from the records the answer just showed, so they always name
|
||||
* something that exists in this workspace.
|
||||
*/
|
||||
|
||||
const answerOverview = (d) => {
|
||||
const t = d.totals;
|
||||
if (!t.totalSpend) return nothingToRead("overview", "billed spend");
|
||||
|
||||
return {
|
||||
topic: "overview",
|
||||
stages: ["Reading Saving Engine data…", `Checking ${plural(t.assignments || 0, "assignment", "assignments")}…`],
|
||||
blocks: blocks(
|
||||
text(`The Saving Engine is tracking **${money(t.totalSpend)}** of billed spend, ${pct(t.contractedRatio, 1)} of it under contract. That leaves **${money(t.totalSavings)}** of savings potential on the ${d.view.timeLabel.toLowerCase()} view.`),
|
||||
savingsMetric(d),
|
||||
list({
|
||||
title: "Workspace",
|
||||
items: [
|
||||
{ label: "Opportunities detected", value: String(t.opportunities) },
|
||||
{ label: "Running without renegotiation", value: String(t.automations) },
|
||||
{ label: "Spend under contract", value: money(t.contractedSpend), tone: "positive" },
|
||||
{ label: "Spend outside contract", value: money(t.nonContractedSpend), tone: t.nonContractedSpend ? "risk" : "neutral" },
|
||||
t.atRisk ? { label: "Savings at risk", value: money(t.atRisk), tone: "risk" } : null,
|
||||
].filter(Boolean),
|
||||
}),
|
||||
actions([
|
||||
d.opportunities.length ? { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" } : null,
|
||||
d.risks.length ? { label: "What savings are at risk?", prompt: "What savings are at risk?" } : null,
|
||||
].filter(Boolean))
|
||||
),
|
||||
followUps: [
|
||||
d.byCategory.length > 1 && { label: "Where are we saving the most?", prompt: "Where are we saving the most?" },
|
||||
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
|
||||
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
|
||||
{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" },
|
||||
].filter(Boolean).slice(0, 3),
|
||||
};
|
||||
};
|
||||
|
||||
const answerWhereMost = (d) => {
|
||||
if (!d.opportunities.length) return nothingToRead("where", "savings opportunities");
|
||||
|
||||
const top = d.opportunities[0];
|
||||
const leader = d.byCategory[0];
|
||||
|
||||
return {
|
||||
topic: "where",
|
||||
stages: ["Reading Saving Engine data…", `Comparing ${plural(d.byCategory.length, "category", "categories")}…`],
|
||||
blocks: blocks(
|
||||
text(`Most savings are currently coming from **${top.title}**. It accounts for ${top.share}% of the total opportunity on this page, and ${leader.label} is the largest category overall.`),
|
||||
savingsMetric(d),
|
||||
progress({
|
||||
title: "Savings by category",
|
||||
items: d.byCategory.map((c) => ({
|
||||
label: c.label,
|
||||
value: c.value,
|
||||
max: leader.value,
|
||||
caption: money(c.value),
|
||||
tone: c.label === leader.label ? "brand" : "neutral",
|
||||
})),
|
||||
}),
|
||||
text(top.detail),
|
||||
actions([
|
||||
{ label: "Open Saving Engine", route: "/SavingsEngine" },
|
||||
{ label: "Show biggest opportunities", prompt: "Show my biggest savings opportunities" },
|
||||
])
|
||||
),
|
||||
followUps: [
|
||||
{ label: `Why is ${top.title.toLowerCase()} #1?`, prompt: `Why is ${top.title} the number one saving?` },
|
||||
{ label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
|
||||
d.trend.length > 1 && { label: "Explain the savings trend", prompt: "Explain the savings trend" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerOpportunities = (d) => {
|
||||
if (!d.opportunities.length) return nothingToRead("opportunities", "savings opportunities");
|
||||
|
||||
const top = topOpportunities(d, 3);
|
||||
const share = top.reduce((n, o) => n + o.share, 0);
|
||||
const rest = d.opportunities.length - top.length;
|
||||
|
||||
return {
|
||||
topic: "opportunities",
|
||||
stages: [`Checking ${plural(d.totals.opportunities, "opportunity", "opportunities")}…`, "Ranking by annualised value…"],
|
||||
blocks: blocks(
|
||||
text(`I found **${plural(top.length, "opportunity", "opportunities")}** worth acting on out of ${d.totals.opportunities} detected. Together they carry ${share}% of the total potential.`),
|
||||
cards(top.map(oppCard)),
|
||||
rest > 0 ? note(`${plural(rest, "smaller opportunity is", "smaller opportunities are")} open behind these.`) : null,
|
||||
actions([
|
||||
rest > 0 ? { label: "Show all opportunities", prompt: "Show all high-value opportunities" } : null,
|
||||
{ label: "Which is easiest to implement?", prompt: "Which one is the easiest to implement?" },
|
||||
].filter(Boolean))
|
||||
),
|
||||
followUps: [
|
||||
{ label: "Which one is easiest to implement?", prompt: "Which one is the easiest to implement?" },
|
||||
top.length > 1 && { label: "Compare these opportunities", prompt: "Compare these opportunities" },
|
||||
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerEasiest = (d) => {
|
||||
if (!d.opportunities.length) return nothingToRead("easiest", "savings opportunities");
|
||||
|
||||
const ranked = [...d.opportunities].sort(
|
||||
(a, b) => a.weeksToValue - b.weeksToValue || b.annual - a.annual
|
||||
);
|
||||
const pick = ranked[0];
|
||||
|
||||
return {
|
||||
topic: "easiest",
|
||||
stages: ["Comparing effort and time-to-value…"],
|
||||
blocks: blocks(
|
||||
text(`**${pick.title}** is the easiest to act on — ${pick.effort} effort and ${plural(pick.weeksToValue, "week", "weeks")} to value.`),
|
||||
metric({
|
||||
label: pick.title,
|
||||
value: money(pick.annual),
|
||||
caption: `per year · ${pick.confidence}% confidence`,
|
||||
sub: [
|
||||
{ label: "Effort", value: pick.effort },
|
||||
{ label: "Time to value", value: `${pick.weeksToValue} wks` },
|
||||
],
|
||||
}),
|
||||
ranked.length > 1 ? compare({
|
||||
title: "Effort vs value",
|
||||
items: ranked.slice(0, 3).map((o) => ({
|
||||
label: o.title,
|
||||
value: money(o.annual, { compact: true }),
|
||||
caption: `${o.weeksToValue} wks · ${o.effort} effort`,
|
||||
tone: o.id === pick.id ? "positive" : "neutral",
|
||||
})),
|
||||
}) : null,
|
||||
text(pick.detail),
|
||||
pick.basis ? note(`Basis — ${pick.basis}`) : null,
|
||||
actions([
|
||||
{ label: `Open ${pick.owner}`, route: pick.route },
|
||||
d.risks.length ? { label: "What savings are at risk?", prompt: "What savings are at risk?" } : null,
|
||||
].filter(Boolean))
|
||||
),
|
||||
followUps: [
|
||||
{ label: `What would ${pick.title.toLowerCase()} take?`, prompt: `What would it take to start ${pick.title}?` },
|
||||
ranked.length > 1 && { label: "Compare these opportunities", prompt: "Compare these opportunities" },
|
||||
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerCompare = (d) => {
|
||||
if (!d.opportunities.length) return nothingToRead("compare", "savings opportunities");
|
||||
|
||||
const top = topOpportunities(d, 4);
|
||||
return {
|
||||
topic: "compare",
|
||||
stages: ["Building comparison…"],
|
||||
blocks: blocks(
|
||||
text("Side by side, ranked by annualised value:"),
|
||||
table({
|
||||
title: "Savings opportunities",
|
||||
columns: [
|
||||
{ key: "title", label: "Opportunity", align: "left" },
|
||||
{ key: "annual", label: "Potential", align: "right", sortable: true, numeric: true },
|
||||
{ key: "effort", label: "Effort", align: "left" },
|
||||
{ key: "status", label: "Status", align: "left", badge: true },
|
||||
],
|
||||
rows: top.map((o) => ({
|
||||
id: o.id,
|
||||
title: o.title,
|
||||
annual: o.annual,
|
||||
annualDisplay: money(o.annual),
|
||||
effort: o.effort,
|
||||
status: o.status,
|
||||
route: o.route,
|
||||
detail: o.detail,
|
||||
})),
|
||||
footnote: "Tap a row for detail · sort by potential",
|
||||
}),
|
||||
actions([
|
||||
{ label: "Which is easiest to implement?", prompt: "Which one is the easiest to implement?" },
|
||||
{ label: "Open Saving Engine", route: "/SavingsEngine" },
|
||||
])
|
||||
),
|
||||
followUps: [
|
||||
{ label: "Which is easiest to implement?", prompt: "Which one is the easiest to implement?" },
|
||||
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
|
||||
{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerTable = (d) => {
|
||||
if (!d.opportunities.length) return nothingToRead("table", "savings opportunities");
|
||||
|
||||
const rows = d.opportunities;
|
||||
return {
|
||||
topic: "table",
|
||||
stages: [`Pulling ${plural(rows.length, "opportunity", "opportunities")}…`],
|
||||
blocks: blocks(
|
||||
text(`All ${plural(rows.length, "open opportunity", "open opportunities")}, highest value first.`),
|
||||
table({
|
||||
title: "Savings opportunities",
|
||||
columns: [
|
||||
{ key: "title", label: "Opportunity", align: "left" },
|
||||
{ key: "annual", label: "Potential", align: "right", sortable: true, numeric: true },
|
||||
{ key: "status", label: "Status", align: "left", badge: true },
|
||||
],
|
||||
rows: rows.map((o) => ({
|
||||
id: o.id,
|
||||
title: o.title,
|
||||
annual: o.annual,
|
||||
annualDisplay: money(o.annual),
|
||||
status: o.status,
|
||||
route: o.route,
|
||||
detail: o.detail,
|
||||
})),
|
||||
footnote: "Tap a row for detail",
|
||||
})
|
||||
),
|
||||
followUps: [
|
||||
rows.length > 1 && { label: "Compare these opportunities", prompt: "Compare these opportunities" },
|
||||
{ label: "Which is easiest to implement?", prompt: "Which one is the easiest to implement?" },
|
||||
d.trend.length > 1 && { label: "Explain the savings trend", prompt: "Explain the savings trend" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerRisk = (d) => {
|
||||
if (!d.risks.length) {
|
||||
return {
|
||||
topic: "risk",
|
||||
stages: ["Checking captured savings…", "Scanning renewals and reliability…"],
|
||||
blocks: blocks(
|
||||
text("Nothing in this workspace is currently at risk of being given back."),
|
||||
status({
|
||||
tone: "positive",
|
||||
label: "No exposure",
|
||||
text: "No vendor certificate or contracted rate expires inside the next 60 days, and no worker holding live assignments is below the reliability threshold.",
|
||||
}),
|
||||
actions([{ label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" }])
|
||||
),
|
||||
followUps: [
|
||||
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
|
||||
{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
const first = d.risks[0];
|
||||
return {
|
||||
topic: "risk",
|
||||
stages: ["Checking captured savings…", "Scanning renewals and reliability…"],
|
||||
blocks: blocks(
|
||||
text(`**${money(d.totals.atRisk)}** of savings is at risk across ${plural(d.risks.length, "item", "items")}. ${first.title} is the largest.`),
|
||||
metric({
|
||||
label: "At-risk savings",
|
||||
value: money(d.totals.atRisk),
|
||||
trend: plural(d.risks.length, "item", "items"),
|
||||
trendTone: "risk",
|
||||
caption: "against savings the workspace already holds",
|
||||
}),
|
||||
cards(d.risks.map(riskCard)),
|
||||
actions([
|
||||
{ label: first.action, route: first.route },
|
||||
{ label: "Open Saving Engine", route: "/SavingsEngine" },
|
||||
])
|
||||
),
|
||||
followUps: [
|
||||
/* The title is a record name — a vendor, a rate book — so its case is
|
||||
left exactly as the record carries it. */
|
||||
{ label: `What if ${first.title} is not addressed?`, prompt: `What happens if ${first.title} is not addressed?` },
|
||||
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
|
||||
d.recentActivity.length && { label: "Show recent saving activity", prompt: "Show recent saving activity" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerRenewal = (d) => {
|
||||
if (!d.risks.length) return answerRisk(d);
|
||||
|
||||
const r = d.risks[0];
|
||||
return {
|
||||
topic: "renewal",
|
||||
stages: ["Modelling the exposure…"],
|
||||
blocks: blocks(
|
||||
text(`If **${r.title}** is left unaddressed, the work behind it reverts to spot pricing at the next billing cycle.`),
|
||||
metric({
|
||||
label: "Exposure if unaddressed",
|
||||
value: money(r.exposure),
|
||||
trend: r.dueInDays > 0 ? `${plural(r.dueInDays, "day", "days")} left` : "live now",
|
||||
trendTone: "risk",
|
||||
caption: "annualised give-back",
|
||||
}),
|
||||
text(r.detail),
|
||||
list({
|
||||
title: "What closes it",
|
||||
items: [
|
||||
{ label: r.action, value: "Owner action", tone: "neutral" },
|
||||
{ label: "Confirm before", value: r.dueInDays > 0 ? plural(r.dueInDays, "day", "days") : "today", tone: "risk" },
|
||||
],
|
||||
}),
|
||||
actions([{ label: r.action, route: r.route }])
|
||||
),
|
||||
followUps: [
|
||||
{ label: "What savings are at risk?", prompt: "What savings are at risk?" },
|
||||
d.recentActivity.length && { label: "Show recent saving activity", prompt: "Show recent saving activity" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerTrend = (d) => {
|
||||
if (d.trend.length < 2) {
|
||||
return {
|
||||
topic: "trend",
|
||||
stages: ["Reading dated assignments…"],
|
||||
blocks: blocks(
|
||||
text("There is not enough dated history in this workspace to read a trend yet."),
|
||||
note(d.trend.length
|
||||
? "Only one month of assignments carries a scheduled date, so there is nothing to compare it against."
|
||||
: "No assignment on this page carries a scheduled date, so no monthly series can be built."),
|
||||
actions([{ label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" }])
|
||||
),
|
||||
followUps: [
|
||||
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
const first = d.trend[0];
|
||||
const last = d.trend[d.trend.length - 1];
|
||||
const prior = d.trend[d.trend.length - 2];
|
||||
const growth = first.value ? ((last.value - first.value) / first.value) * 100 : null;
|
||||
const mom = prior.value ? ((last.value - prior.value) / prior.value) * 100 : null;
|
||||
const rising = last.value >= first.value;
|
||||
|
||||
return {
|
||||
topic: "trend",
|
||||
stages: [`Reading ${plural(d.trend.length, "month", "months")} of assignments…`, "Fitting the trend…"],
|
||||
blocks: blocks(
|
||||
text(`Savings run-rate has ${rising ? "risen" : "fallen"} across the period — ${money(first.value, { compact: true })} in ${first.label} to ${money(last.value, { compact: true })} in ${last.label}.`),
|
||||
chart({
|
||||
title: "Monthly savings run-rate",
|
||||
variant: "area",
|
||||
data: d.trend,
|
||||
format: "money",
|
||||
summary: growth == null ? `${d.trend.length} months tracked` : `${growth >= 0 ? "↑" : "↓"} ${pct(Math.abs(growth), 1)} over ${plural(d.trend.length, "month", "months")}`,
|
||||
}),
|
||||
metric({
|
||||
label: "Month over month",
|
||||
value: mom == null ? "—" : `${mom >= 0 ? "+" : "−"}${pct(Math.abs(mom), 1)}`,
|
||||
caption: `${last.label} vs ${prior.label}`,
|
||||
sub: [
|
||||
{ label: "This month", value: money(last.value) },
|
||||
{ label: "Period total", value: money(d.trend.reduce((n, t) => n + t.value, 0)) },
|
||||
],
|
||||
}),
|
||||
note("Run-rate is the spend outside a contracted rate in each month, at the rate spread this workspace is currently carrying."),
|
||||
actions([
|
||||
{ label: "Show biggest contributors", prompt: "Show the biggest contributors to the savings trend" },
|
||||
{ label: "Open Saving Engine", route: "/SavingsEngine" },
|
||||
])
|
||||
),
|
||||
followUps: [
|
||||
{ label: "What caused the change?", prompt: "What caused the change in savings?" },
|
||||
{ label: "Show biggest contributors", prompt: "Show the biggest contributors to the savings trend" },
|
||||
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerContributors = (d) => {
|
||||
if (!d.opportunities.length) return nothingToRead("contributors", "savings opportunities");
|
||||
|
||||
const total = d.opportunities.reduce((n, o) => n + o.annual, 0);
|
||||
const lead = d.opportunities[0];
|
||||
|
||||
return {
|
||||
topic: "contributors",
|
||||
stages: ["Attributing the movement…"],
|
||||
blocks: blocks(
|
||||
text(`The potential is concentrated in ${plural(Math.min(d.opportunities.length, 4), "place", "places")}:`),
|
||||
progress({
|
||||
title: "Contribution to potential",
|
||||
items: d.opportunities.slice(0, 4).map((o) => ({
|
||||
label: o.title,
|
||||
value: o.share,
|
||||
max: 100,
|
||||
caption: `${o.share}% · ${money(o.annual, { compact: true })}`,
|
||||
tone: o.id === lead.id ? "brand" : "neutral",
|
||||
})),
|
||||
}),
|
||||
text(`${lead.title} carries ${lead.share}% of the ${money(total)} total on its own. ${lead.detail}`),
|
||||
actions([{ label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" }])
|
||||
),
|
||||
followUps: [
|
||||
d.trend.length > 1 && { label: "Explain the savings trend", prompt: "Explain the savings trend" },
|
||||
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerHowItWorks = (d) => {
|
||||
if (!d.pipeline.length) return nothingToRead("how", "workspace data");
|
||||
|
||||
return {
|
||||
topic: "how",
|
||||
stages: ["Tracing the savings pipeline…"],
|
||||
blocks: blocks(
|
||||
text("The Saving Engine runs a pipeline over the spend records connected to this workspace. Each stage narrows raw spend down to a captured saving:"),
|
||||
flow({ title: "Savings pipeline", nodes: d.pipeline }),
|
||||
note("Tap any stage for what it is doing on this page right now."),
|
||||
actions([
|
||||
d.opportunities.length ? { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" } : null,
|
||||
{ label: "Open Saving Engine", route: "/SavingsEngine" },
|
||||
].filter(Boolean))
|
||||
),
|
||||
followUps: [
|
||||
{ label: "What is waiting on approval?", prompt: "What is waiting on approval?" },
|
||||
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
|
||||
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerApprovals = (d) => {
|
||||
const waiting = d.opportunities.filter((o) => o.annual >= 10000);
|
||||
if (!waiting.length) {
|
||||
return {
|
||||
topic: "approvals",
|
||||
stages: ["Checking the approval queue…"],
|
||||
blocks: blocks(
|
||||
text("Nothing in this workspace needs finance sign-off."),
|
||||
note("Anything above $10K annualised needs approval before it can run unattended. No detected opportunity clears that line."),
|
||||
actions(d.opportunities.length
|
||||
? [{ label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" }] : [])
|
||||
),
|
||||
followUps: [{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" }],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
topic: "approvals",
|
||||
stages: ["Checking the approval queue…"],
|
||||
blocks: blocks(
|
||||
text(`${plural(waiting.length, "recommendation is", "recommendations are")} above the $10K annualised line and ${waiting.length === 1 ? "needs" : "need"} finance sign-off before running unattended.`),
|
||||
list({
|
||||
title: "Awaiting approval",
|
||||
items: waiting.map((o) => ({
|
||||
label: o.title,
|
||||
value: `${money(o.annual)} · ${o.owner}`,
|
||||
tone: o.impact === "high" ? "risk" : "neutral",
|
||||
})),
|
||||
}),
|
||||
actions([{ label: "Open Saving Engine", route: "/SavingsEngine" }])
|
||||
),
|
||||
followUps: [
|
||||
{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" },
|
||||
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerAutomations = (d) => {
|
||||
const automatable = d.opportunities.filter((o) => o.effort === "low");
|
||||
const negotiated = d.opportunities.length - automatable.length;
|
||||
|
||||
if (!d.opportunities.length) return nothingToRead("automations", "savings rules");
|
||||
|
||||
return {
|
||||
topic: "automations",
|
||||
stages: ["Checking automation state…"],
|
||||
blocks: blocks(
|
||||
text(`${plural(automatable.length, "opportunity", "opportunities")} can run without renegotiation; the other ${negotiated} need${negotiated === 1 ? "s" : ""} a commercial conversation first.`),
|
||||
status({
|
||||
tone: automatable.length ? "positive" : "warning",
|
||||
label: automatable.length ? `${automatable.length} ready to automate` : "None ready",
|
||||
text: automatable.length
|
||||
? `${automatable.map((o) => o.title).join(", ")} rest on rates that already exist, so they can be enforced as standing rules.`
|
||||
: "Every detected opportunity needs a rate or contract change before it can be enforced automatically.",
|
||||
}),
|
||||
list({
|
||||
title: "Automation health",
|
||||
items: [
|
||||
{ label: "Runs unattended", value: String(automatable.length), tone: automatable.length ? "positive" : "neutral" },
|
||||
{ label: "Needs negotiation", value: String(negotiated), tone: negotiated ? "risk" : "neutral" },
|
||||
{ label: "Detections active", value: String(d.totals.activeRules) },
|
||||
],
|
||||
}),
|
||||
actions([{ label: "Open vendor rates", route: "/VendorRates" }])
|
||||
),
|
||||
followUps: [
|
||||
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
|
||||
{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerActivity = (d) => {
|
||||
if (!d.recentActivity.length) return nothingToRead("activity", "recent activity");
|
||||
|
||||
return {
|
||||
topic: "activity",
|
||||
stages: [`Reading ${plural(d.recentActivity.length, "recent event", "recent events")}…`],
|
||||
blocks: blocks(
|
||||
text(`The ${plural(d.recentActivity.length, "most recent change", "most recent changes")} in the records this page reads:`),
|
||||
timeline({ title: "Recent activity", items: d.recentActivity }),
|
||||
actions([{ label: "Open activity log", route: "/ActivityLog" }])
|
||||
),
|
||||
followUps: [
|
||||
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
|
||||
d.trend.length > 1 && { label: "Explain the savings trend", prompt: "Explain the savings trend" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
const answerNumbers = (d) => {
|
||||
const t = d.totals;
|
||||
if (!t.totalSpend) return nothingToRead("numbers", "billed spend");
|
||||
|
||||
return {
|
||||
topic: "numbers",
|
||||
stages: ["Pulling the headline figures…"],
|
||||
blocks: blocks(
|
||||
text("The figures this page is currently carrying:"),
|
||||
savingsMetric(d),
|
||||
table({
|
||||
title: "Saving Engine metrics",
|
||||
columns: [
|
||||
{ key: "label", label: "Metric", align: "left" },
|
||||
{ key: "value", label: "Value", align: "right", sortable: false },
|
||||
],
|
||||
rows: [
|
||||
{ id: "spend", label: "Total billed spend", value: money(t.totalSpend) },
|
||||
{ id: "contracted", label: "Under contract", value: `${money(t.contractedSpend)} (${pct(t.contractedRatio, 1)})` },
|
||||
{ id: "spot", label: "Outside contract", value: money(t.nonContractedSpend) },
|
||||
{ id: "rate", label: "Rate spread", value: `$${Number(t.rateDelta || 0).toFixed(2)}/hr` },
|
||||
{ id: "fill", label: "Fill rate", value: pct(t.fillRate, 1) },
|
||||
{ id: "reliability", label: "Avg reliability", value: pct(t.avgReliability, 0) },
|
||||
{ id: "noshow", label: "No-show rate", value: pct(t.noShowRate, 1) },
|
||||
{ id: "vendors", label: "Active vendors", value: String(t.activeVendors) },
|
||||
{ id: "workforce", label: "Workforce", value: String(t.totalWorkforce) },
|
||||
],
|
||||
}),
|
||||
actions([{ label: "Open Saving Engine", route: "/SavingsEngine" }])
|
||||
),
|
||||
followUps: [
|
||||
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
|
||||
d.byCategory.length > 1 && { label: "Where are we saving the most?", prompt: "Where are we saving the most?" },
|
||||
].filter(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
/* ============================================================== dispatcher */
|
||||
|
||||
/**
|
||||
* Keyword routing. Deliberately shallow — enough to prove the interaction, and
|
||||
* ordered so the specific patterns win over the broad ones. Follow-ups are
|
||||
* resolved first, against `lastTopic`, so "which one is easiest?" is understood
|
||||
* as being about the cards Owliver just showed.
|
||||
*/
|
||||
const ROUTES = [
|
||||
[/\beasiest|simplest|quickest|least effort|low effort|fastest to\b/i, answerEasiest],
|
||||
[/\bcompare|side by side|versus|vs\b/i, answerCompare],
|
||||
[/\ball (the )?(high.?value|opportunities)|show all|full list|every opportunity|table\b/i, answerTable],
|
||||
[/\bcaused|why (did|is|has).*(increase|rise|grow|change)|contributor|driving\b/i, answerContributors],
|
||||
[/\brenewal|lapse|expire|not addressed|unaddressed|what happens if\b/i, answerRenewal],
|
||||
[/\bapprov|sign.?off\b/i, answerApprovals],
|
||||
[/\bautomation|rules? running|paused|unattended\b/i, answerAutomations],
|
||||
[/\bhow does|how it works|how the .*works?|explain the process|pipeline|workflow\b/i, answerHowItWorks],
|
||||
[/\btrend|over time|last (six|6) months|month over month|growth\b/i, answerTrend],
|
||||
[/\brisk|at risk|exposure|losing|give back|lapsing\b/i, answerRisk],
|
||||
[/\b(recent )?activity|what happened|latest|log\b/i, answerActivity],
|
||||
[/\bimportant numbers|key numbers|metrics|figures|numbers\b/i, answerNumbers],
|
||||
[/\bopportunit|biggest|top saving|where can (we|i) save|potential\b/i, answerOpportunities],
|
||||
[/\bwhere .*(saving|save)|most saving|saving the most|which category|breakdown\b/i, answerWhereMost],
|
||||
[/\bhow much|total saving|overview|summary|summar|status\b/i, answerOverview],
|
||||
];
|
||||
|
||||
/** Pronoun follow-ups ("which one…", "compare these") resolved against topic. */
|
||||
const FOLLOW_UP = /\b(one|ones|these|those|them|it|that|this)\b/i;
|
||||
|
||||
/**
|
||||
* What a pronoun means, given what Owliver just said.
|
||||
*
|
||||
* This is the whole of conversational context on this page: the previous
|
||||
* answer's topic decides which reading "the biggest" refers to, so a follow-up
|
||||
* stays inside the same page and the same set of records it was asked about.
|
||||
*/
|
||||
const FROM_TOPIC = {
|
||||
overview: [
|
||||
[/\bbiggest|largest|most\b/i, answerOpportunities],
|
||||
[/\brisk\b/i, answerRisk],
|
||||
[/\bbreak ?down|categor\b/i, answerWhereMost],
|
||||
],
|
||||
opportunities: [
|
||||
[/\beasiest|simplest|quickest|least effort\b/i, answerEasiest],
|
||||
[/\bcompare|rank|side by side\b/i, answerCompare],
|
||||
[/\bbiggest|largest|worth most\b/i, answerCompare],
|
||||
[/\bnext|more|others?|all\b/i, answerTable],
|
||||
[/\brisk\b/i, answerRisk],
|
||||
],
|
||||
where: [
|
||||
[/\bwhy\b/i, answerContributors],
|
||||
[/\bbiggest|largest\b/i, answerOpportunities],
|
||||
],
|
||||
easiest: [
|
||||
[/\bstart|begin|take|do it|what would it\b/i, answerEasiest],
|
||||
[/\bcompare\b/i, answerCompare],
|
||||
[/\bbiggest|largest\b/i, answerOpportunities],
|
||||
],
|
||||
compare: [
|
||||
[/\beasiest|simplest\b/i, answerEasiest],
|
||||
[/\bbiggest|largest|first\b/i, answerOpportunities],
|
||||
],
|
||||
table: [
|
||||
[/\beasiest|simplest\b/i, answerEasiest],
|
||||
[/\bbiggest|largest\b/i, answerOpportunities],
|
||||
],
|
||||
trend: [
|
||||
[/\bcaused|why|driver|contributor\b/i, answerContributors],
|
||||
[/\bcompare|last month\b/i, answerContributors],
|
||||
],
|
||||
contributors: [
|
||||
[/\bbiggest|largest|first\b/i, answerOpportunities],
|
||||
[/\btrend\b/i, answerTrend],
|
||||
],
|
||||
risk: [
|
||||
[/\bwhat happens|if|unaddressed\b/i, answerRenewal],
|
||||
[/\bfirst|worst|biggest\b/i, answerRenewal],
|
||||
],
|
||||
renewal: [
|
||||
[/\bother|rest|else\b/i, answerRisk],
|
||||
],
|
||||
how: [
|
||||
[/\bapprov|waiting\b/i, answerApprovals],
|
||||
[/\bautomat\b/i, answerAutomations],
|
||||
],
|
||||
numbers: [
|
||||
[/\bbiggest|largest\b/i, answerOpportunities],
|
||||
[/\bbreak ?down|categor\b/i, answerWhereMost],
|
||||
],
|
||||
};
|
||||
|
||||
/** The dead end, which still has to be useful and still page-bound. */
|
||||
const answerUnknown = (d) => ({
|
||||
topic: null,
|
||||
stages: ["Reading Saving Engine data…"],
|
||||
blocks: blocks(
|
||||
text("I can analyse anything in the current Saving Engine workspace, but I couldn't map that question onto it."),
|
||||
actions([
|
||||
{ label: "Summary", prompt: "Give me a summary" },
|
||||
d.opportunities.length ? { label: "Opportunities", prompt: "Show my biggest savings opportunities" } : null,
|
||||
d.risks.length ? { label: "Risks", prompt: "What savings are at risk?" } : null,
|
||||
d.trend.length > 1 ? { label: "Trends", prompt: "Explain the savings trend" } : null,
|
||||
{ label: "How it works", prompt: "How does the Saving Engine work?" },
|
||||
].filter(Boolean), { title: "Try asking about" })
|
||||
),
|
||||
followUps: [],
|
||||
});
|
||||
|
||||
/* ================================================================== export */
|
||||
|
||||
export const savingEngine = {
|
||||
key: "savingEngine",
|
||||
title: "Saving Engine",
|
||||
entity: "SavingsOpportunity",
|
||||
placeholder: "Ask Owliver about Saving Engine...",
|
||||
contextLabel: "Using Saving Engine data",
|
||||
working: "Reading Saving Engine data",
|
||||
|
||||
contextFacts: (signals) => {
|
||||
const d = workspace(signals);
|
||||
return [
|
||||
{ value: money(d.totals.totalSavings, { compact: true }), label: "potential" },
|
||||
{ value: d.totals.opportunities || 0, label: "opportunities" },
|
||||
d.totals.atRisk ? { value: money(d.totals.atRisk, { compact: true }), label: "at risk", tone: "risk" } : null,
|
||||
].filter(Boolean);
|
||||
},
|
||||
|
||||
/** The dynamic "current read" — short lines, all from this page's records. */
|
||||
read: (signals) => {
|
||||
const d = workspace(signals);
|
||||
const t = d.totals;
|
||||
|
||||
if (!t.totalSpend) {
|
||||
return {
|
||||
tone: "neutral",
|
||||
headline: "No billed spend on this page yet.",
|
||||
lines: ["Owliver reads the assignments, vendors, rates and workforce this page has loaded."],
|
||||
stat: null,
|
||||
};
|
||||
}
|
||||
|
||||
const lead = d.opportunities[0];
|
||||
return {
|
||||
tone: t.atRisk ? "warning" : "positive",
|
||||
headline: lead
|
||||
? `${lead.title} is the largest opportunity on this page.`
|
||||
: `${pct(t.contractedRatio, 1)} of billed spend is already under contract.`,
|
||||
lines: [
|
||||
`**${money(t.monthlyPotential, { compact: true })}** in monthly savings potential across ${plural(t.opportunities, "opportunity", "opportunities")}.`,
|
||||
lead
|
||||
? `**${lead.title} carries ${lead.share}%** of the total potential, at ${money(lead.annual)} a year.`
|
||||
: `**${money(t.contractedSpend)}** of ${money(t.totalSpend)} runs at contracted rates.`,
|
||||
t.atRisk ? `**${money(t.atRisk)}** is at risk across ${plural(d.risks.length, "item", "items")}.` : null,
|
||||
].filter(Boolean),
|
||||
stat: {
|
||||
label: `${d.view.timeLabel} projection`,
|
||||
value: money(t.totalSavings),
|
||||
trend: trendPct(d) == null ? undefined : `↑ ${pct(Math.abs(trendPct(d)), 1)}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The questions this page's data makes worth asking.
|
||||
*
|
||||
* Derived, not listed: a chip is only offered when the workspace has the
|
||||
* records to answer it, and the ones that name a record name a real one.
|
||||
*/
|
||||
suggestions: (signals) => {
|
||||
const d = workspace(signals);
|
||||
const lead = d.opportunities[0];
|
||||
|
||||
return [
|
||||
{ label: "Give me a summary", prompt: "Give me a summary" },
|
||||
d.byCategory.length > 1 && { label: "Where are we saving the most?", prompt: "Where are we saving the most?" },
|
||||
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
|
||||
d.risks.length && { label: `What savings are at risk?`, prompt: "What savings are at risk?" },
|
||||
lead && { label: `Why is ${lead.title.toLowerCase()} #1?`, prompt: `Why is ${lead.title} the number one saving?` },
|
||||
d.trend.length > 1 && { label: "Explain the savings trend", prompt: "Explain the savings trend" },
|
||||
{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" },
|
||||
{ label: "Show the important numbers", prompt: "Show the important numbers" },
|
||||
].filter(Boolean);
|
||||
},
|
||||
|
||||
answer({ prompt, signals, lastTopic }) {
|
||||
const d = workspace(signals);
|
||||
const q = String(prompt || "");
|
||||
|
||||
// A pronoun means the question is about the last answer, so that answer's
|
||||
// own follow-up table gets first refusal.
|
||||
if (FOLLOW_UP.test(q) && lastTopic && FROM_TOPIC[lastTopic]) {
|
||||
const hit = FROM_TOPIC[lastTopic].find(([pattern]) => pattern.test(q));
|
||||
if (hit) return hit[1](d);
|
||||
}
|
||||
|
||||
const route = ROUTES.find(([pattern]) => pattern.test(q));
|
||||
if (route) return route[1](d);
|
||||
|
||||
// "saving"/"save" with nothing more specific is the overview.
|
||||
if (/\bsavings?|save|spend|cost\b/i.test(q)) return answerOverview(d);
|
||||
|
||||
return answerUnknown(d);
|
||||
},
|
||||
};
|
||||
|
||||
export default savingEngine;
|
||||
673
src/lib/owliver/knowledge/savingsWorkspace.js
Normal file
673
src/lib/owliver/knowledge/savingsWorkspace.js
Normal file
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* Saving Engine — workspace derivation
|
||||
* ------------------------------------
|
||||
* The Saving Engine page's own records, read into the shape Owliver answers
|
||||
* from. One derivation, two callers:
|
||||
*
|
||||
* the page registers assignments, vendors, workforce, orders and rates
|
||||
* through `useOwliverPageContext`, and Owliver reads what is
|
||||
* already on screen
|
||||
* the route when the panel is opened on a Saving Engine route the page has
|
||||
* not mounted, `DOMAINS.savingEngine.loadSignals()` fetches the
|
||||
* same entities and runs this same function
|
||||
*
|
||||
* That is the whole point of putting it here rather than in either caller: the
|
||||
* figure Owliver quotes and the figure the card beside it draws come from one
|
||||
* computation, so they cannot disagree.
|
||||
*
|
||||
* Every number below is traceable to a record. Where the page has no basis for
|
||||
* something — no prior window to compare against, no rate book to check a bill
|
||||
* rate against — the field is omitted rather than filled with a plausible
|
||||
* value, and the answers read as "nothing to report" instead of inventing one.
|
||||
*/
|
||||
|
||||
const num = (v) => Number(v || 0);
|
||||
const round = (v) => Math.round(num(v));
|
||||
const safeDiv = (a, b) => (num(b) ? num(a) / num(b) : 0);
|
||||
|
||||
const TIME_LABEL = {
|
||||
"7days": "7-day", "30days": "30-day", quarter: "Quarterly", year: "Annual",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ dates */
|
||||
|
||||
const asDate = (value) => {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
};
|
||||
|
||||
const monthKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
const monthLabel = (d) => d.toLocaleDateString("en-US", { month: "short" });
|
||||
|
||||
const daysUntil = (value) => {
|
||||
const d = asDate(value);
|
||||
if (!d) return null;
|
||||
return Math.ceil((d.getTime() - Date.now()) / 86400000);
|
||||
};
|
||||
|
||||
const relativeTime = (value) => {
|
||||
const d = asDate(value);
|
||||
if (!d) return null;
|
||||
const hours = Math.round((Date.now() - d.getTime()) / 3600000);
|
||||
if (hours < 1) return "just now";
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
if (days === 1) return "yesterday";
|
||||
if (days < 0) return `in ${Math.abs(days)}d`;
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- metrics */
|
||||
|
||||
/**
|
||||
* The page's own metric block, recomputed.
|
||||
*
|
||||
* Deliberately the same arithmetic as `SavingsEngine.jsx` — when the page hands
|
||||
* its own `metrics` over, that object wins and this is never used. It exists so
|
||||
* the route-level fallback produces the identical shape rather than a second,
|
||||
* subtly different reading of the same records.
|
||||
*/
|
||||
export function deriveMetrics({ assignments = [], vendors = [], workforce = [], orders = [], rates = [] }) {
|
||||
const totalSpend = assignments.reduce((n, a) => n + num(a.total_bill), 0);
|
||||
const contractedSpend = assignments
|
||||
.filter((a) => a.vendor_id)
|
||||
.reduce((n, a) => n + num(a.total_bill), 0);
|
||||
const nonContractedSpend = totalSpend - contractedSpend;
|
||||
const contractedRatio = totalSpend > 0 ? (contractedSpend / totalSpend) * 100 : 0;
|
||||
|
||||
const approved = rates.filter((r) => r.is_approved_rate);
|
||||
const unapproved = rates.filter((r) => !r.is_approved_rate);
|
||||
const avgContractedRate = safeDiv(
|
||||
approved.reduce((n, r) => n + num(r.client_rate), 0), Math.max(approved.length, 1)
|
||||
);
|
||||
const avgNonContractedRate = safeDiv(
|
||||
unapproved.reduce((n, r) => n + num(r.client_rate), 0), Math.max(unapproved.length, 1)
|
||||
);
|
||||
|
||||
const potentialSavingsPercent = avgNonContractedRate > avgContractedRate
|
||||
? ((avgNonContractedRate - avgContractedRate) / avgNonContractedRate) * 100
|
||||
: 15;
|
||||
const potentialSavings = nonContractedSpend * (potentialSavingsPercent / 100);
|
||||
|
||||
const avgReliability = safeDiv(
|
||||
workforce.reduce((n, w) => n + num(w.reliability_index ?? 75), 0), Math.max(workforce.length, 1)
|
||||
);
|
||||
const noShowRate = safeDiv(
|
||||
workforce.reduce((n, w) => n + num(w.no_shows), 0),
|
||||
Math.max(workforce.reduce((n, w) => n + num(w.total_assignments || 1), 0), 1)
|
||||
) * 100;
|
||||
const fillRate = safeDiv(
|
||||
assignments.filter((a) => a.assignment_status === "Completed").length,
|
||||
Math.max(assignments.length, 1)
|
||||
) * 100;
|
||||
|
||||
return {
|
||||
totalSpend, contractedSpend, nonContractedSpend, contractedRatio,
|
||||
potentialSavings, potentialSavingsPercent,
|
||||
avgContractedRate: avgContractedRate || 45,
|
||||
avgNonContractedRate: avgNonContractedRate || 55,
|
||||
avgReliability, noShowRate, fillRate,
|
||||
activeVendors: vendors.filter((v) => v.is_active).length,
|
||||
totalWorkforce: workforce.length,
|
||||
completedOrders: orders.filter((o) => o.order_status === "Completed").length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The page's projection model, recomputed on the same terms. */
|
||||
export const deriveProjections = (metrics) => {
|
||||
const daily = safeDiv(metrics.potentialSavings, 30);
|
||||
return {
|
||||
sevenDays: daily * 7,
|
||||
thirtyDays: num(metrics.potentialSavings),
|
||||
quarter: num(metrics.potentialSavings) * 3,
|
||||
year: num(metrics.potentialSavings) * 12,
|
||||
};
|
||||
};
|
||||
|
||||
const projectedFor = (projections, timeRange) => {
|
||||
switch (timeRange) {
|
||||
case "7days": return num(projections.sevenDays);
|
||||
case "quarter": return num(projections.quarter);
|
||||
case "year": return num(projections.year);
|
||||
default: return num(projections.thirtyDays);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------- rate evidence */
|
||||
|
||||
/**
|
||||
* The approved bill rate per role, from the rate book the workspace actually
|
||||
* carries. An assignment billed above it is the evidence behind rate drift, and
|
||||
* a role the rate book does not cover is left out rather than assumed.
|
||||
*/
|
||||
const approvedRateByRole = (rates) => {
|
||||
const map = new Map();
|
||||
for (const rate of rates) {
|
||||
if (!rate.is_approved_rate || !rate.role_name) continue;
|
||||
const current = map.get(rate.role_name);
|
||||
const value = num(rate.client_rate);
|
||||
if (!value) continue;
|
||||
if (current == null || value < current) map.set(rate.role_name, value);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
/** The cheapest bill rate any vendor has actually charged for a role. */
|
||||
const bestObservedRateByRole = (assignments) => {
|
||||
const map = new Map();
|
||||
for (const a of assignments) {
|
||||
const rate = num(a.bill_rate);
|
||||
if (!a.role || !rate) continue;
|
||||
const current = map.get(a.role);
|
||||
if (current == null || rate < current) map.set(a.role, rate);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------- opportunities */
|
||||
|
||||
const IMPACT = (share) => (share >= 25 ? "high" : share >= 10 ? "medium" : "low");
|
||||
|
||||
/**
|
||||
* The savings the workspace's own records support.
|
||||
*
|
||||
* Each entry carries the records it was read from, so an answer can state its
|
||||
* basis rather than asserting a figure. An opportunity with no basis in the
|
||||
* data is not produced at all — which is why this returns a list whose length
|
||||
* changes with the workspace instead of a fixed set of five.
|
||||
*/
|
||||
function deriveOpportunities({ assignments, vendors, rates, metrics }) {
|
||||
const found = [];
|
||||
|
||||
/* 1 — Spend running outside a contracted rate. */
|
||||
if (metrics.nonContractedSpend > 0) {
|
||||
const monthly = num(metrics.potentialSavings);
|
||||
found.push({
|
||||
id: "opp-contract-conversion",
|
||||
title: "Contract conversion",
|
||||
category: "Vendor",
|
||||
monthly,
|
||||
annual: monthly * 12,
|
||||
effort: "medium",
|
||||
status: metrics.contractedRatio >= 70 ? "Review" : "Ready",
|
||||
confidence: Math.min(95, round(metrics.contractedRatio) + 20),
|
||||
owner: "Procurement",
|
||||
weeksToValue: 6,
|
||||
route: "/VendorManagement",
|
||||
detail: `${round(metrics.nonContractedSpend).toLocaleString()} dollars of ${round(metrics.totalSpend).toLocaleString()} in billed spend runs outside a contracted rate. Moving it from the spot average of $${metrics.avgNonContractedRate.toFixed(2)}/hr to the contracted $${metrics.avgContractedRate.toFixed(2)}/hr closes a ${metrics.potentialSavingsPercent.toFixed(1)}% spread.`,
|
||||
basis: `${round(metrics.contractedRatio)}% of spend is under contract today.`,
|
||||
});
|
||||
}
|
||||
|
||||
/* 2 — Assignments billed above the approved rate book. */
|
||||
const approved = approvedRateByRole(rates);
|
||||
if (approved.size) {
|
||||
let overage = 0;
|
||||
const roles = new Map();
|
||||
for (const a of assignments) {
|
||||
const book = approved.get(a.role);
|
||||
const billed = num(a.bill_rate);
|
||||
if (!book || !billed || billed <= book) continue;
|
||||
const share = (billed - book) / billed;
|
||||
const amount = num(a.total_bill) * share;
|
||||
overage += amount;
|
||||
roles.set(a.role, num(roles.get(a.role)) + amount);
|
||||
}
|
||||
if (overage > 0) {
|
||||
const worst = [...roles.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
found.push({
|
||||
id: "opp-rate-drift",
|
||||
title: "Rate card drift",
|
||||
category: "Rates",
|
||||
monthly: overage,
|
||||
annual: overage * 12,
|
||||
effort: "low",
|
||||
status: "Ready",
|
||||
confidence: 90,
|
||||
owner: "Procurement",
|
||||
weeksToValue: 2,
|
||||
route: "/VendorRates",
|
||||
detail: `${roles.size} role${roles.size === 1 ? " is" : "s are"} being billed above the approved rate book${worst ? `, led by ${worst[0]} at $${round(worst[1]).toLocaleString()} of the overage` : ""}. Nothing needs renegotiating — the contracted rate already exists.`,
|
||||
basis: `Checked against ${approved.size} approved role rate${approved.size === 1 ? "" : "s"}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* 3 — The same role filled at different rates by different vendors. */
|
||||
const best = bestObservedRateByRole(assignments);
|
||||
const vendorsByRole = new Map();
|
||||
for (const a of assignments) {
|
||||
if (!a.role || !a.vendor_id) continue;
|
||||
if (!vendorsByRole.has(a.role)) vendorsByRole.set(a.role, new Set());
|
||||
vendorsByRole.get(a.role).add(a.vendor_id);
|
||||
}
|
||||
const splitRoles = [...vendorsByRole.entries()].filter(([, set]) => set.size > 1);
|
||||
if (splitRoles.length) {
|
||||
let spread = 0;
|
||||
for (const a of assignments) {
|
||||
const floor = best.get(a.role);
|
||||
const billed = num(a.bill_rate);
|
||||
if (!floor || !billed || billed <= floor) continue;
|
||||
if (!vendorsByRole.get(a.role) || vendorsByRole.get(a.role).size < 2) continue;
|
||||
spread += num(a.total_bill) * ((billed - floor) / billed);
|
||||
}
|
||||
if (spread > 0) {
|
||||
const supplying = new Set(assignments.filter((a) => a.vendor_id).map((a) => a.vendor_id)).size;
|
||||
found.push({
|
||||
id: "opp-vendor-consolidation",
|
||||
title: "Vendor consolidation",
|
||||
category: "Vendor",
|
||||
monthly: spread,
|
||||
annual: spread * 12,
|
||||
effort: "medium",
|
||||
status: "Active",
|
||||
confidence: 82,
|
||||
owner: "Procurement",
|
||||
weeksToValue: 8,
|
||||
route: "/VendorManagement",
|
||||
detail: `${splitRoles.length} role${splitRoles.length === 1 ? " is" : "s are"} supplied by more than one vendor at different bill rates across ${supplying} supplying vendor${supplying === 1 ? "" : "s"}. Consolidating each role onto its lowest observed rate closes the spread.`,
|
||||
basis: splitRoles.map(([role]) => role).slice(0, 4).join(", "),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* 4 — Cost carried by shifts that were not worked. */
|
||||
if (metrics.noShowRate > 0 && metrics.totalSpend > 0) {
|
||||
const monthly = num(metrics.totalSpend) * (num(metrics.noShowRate) / 100);
|
||||
found.push({
|
||||
id: "opp-no-show",
|
||||
title: "No-show leakage",
|
||||
category: "Labor",
|
||||
monthly,
|
||||
annual: monthly * 12,
|
||||
effort: "medium",
|
||||
status: "Active",
|
||||
confidence: 71,
|
||||
owner: "Operations",
|
||||
weeksToValue: 8,
|
||||
route: "/StaffDirectory",
|
||||
detail: `A ${metrics.noShowRate.toFixed(1)}% no-show rate across ${metrics.totalWorkforce} workers carries replacement cover at premium rates. Reliability currently averages ${metrics.avgReliability.toFixed(0)}%.`,
|
||||
basis: `${metrics.totalWorkforce} workforce records with attendance history.`,
|
||||
});
|
||||
}
|
||||
|
||||
/* 5 — Vendors carrying no work at all. */
|
||||
const supplying = new Set(assignments.map((a) => a.vendor_id).filter(Boolean));
|
||||
const idle = vendors.filter((v) => v.is_active && !supplying.has(v.id));
|
||||
if (idle.length > 1 && metrics.totalSpend > 0) {
|
||||
/* No spend flows through these, so the saving is the administrative and
|
||||
compliance carry rather than a rate — modelled from the fee they would
|
||||
take on an average order's worth of work. */
|
||||
const monthly = safeDiv(metrics.totalSpend, Math.max(supplying.size, 1)) * 0.05 * idle.length;
|
||||
found.push({
|
||||
id: "opp-vendor-tail",
|
||||
title: "Inactive vendor tail",
|
||||
category: "Vendor",
|
||||
monthly,
|
||||
annual: monthly * 12,
|
||||
effort: "low",
|
||||
status: "Review",
|
||||
confidence: 64,
|
||||
owner: "Procurement",
|
||||
weeksToValue: 4,
|
||||
route: "/VendorManagement",
|
||||
detail: `${idle.length} active vendor${idle.length === 1 ? " carries" : "s carry"} no assignments in this window while ${supplying.size} supply all billed work. Each one still costs compliance review and rate-book upkeep.`,
|
||||
basis: idle.map((v) => v.doing_business_as || v.legal_name).filter(Boolean).slice(0, 4).join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
const total = found.reduce((n, o) => n + o.annual, 0);
|
||||
return found
|
||||
.map((o) => ({
|
||||
...o,
|
||||
annual: round(o.annual),
|
||||
monthly: round(o.monthly),
|
||||
share: round(safeDiv(o.annual, total) * 100),
|
||||
}))
|
||||
.map((o) => ({ ...o, impact: IMPACT(o.share) }))
|
||||
.sort((a, b) => b.annual - a.annual);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ risks */
|
||||
|
||||
/**
|
||||
* Savings already in hand that the workspace is on course to give back.
|
||||
*
|
||||
* All three read a real expiry or a real score. A workspace with none of those
|
||||
* conditions returns an empty list, and the risk answer says so.
|
||||
*/
|
||||
function deriveRisks({ assignments, vendors, workforce, rates, metrics }) {
|
||||
const risks = [];
|
||||
const spendByVendor = new Map();
|
||||
for (const a of assignments) {
|
||||
if (!a.vendor_id) continue;
|
||||
spendByVendor.set(a.vendor_id, num(spendByVendor.get(a.vendor_id)) + num(a.total_bill));
|
||||
}
|
||||
|
||||
/* Insurance lapsing on a vendor that is currently carrying work. */
|
||||
for (const vendor of vendors) {
|
||||
const due = daysUntil(vendor.insurance_expiry);
|
||||
const spend = num(spendByVendor.get(vendor.id));
|
||||
if (due == null || due > 90 || !spend) continue;
|
||||
risks.push({
|
||||
id: `risk-insurance-${vendor.id}`,
|
||||
title: `${vendor.doing_business_as || vendor.legal_name} insurance expiry`,
|
||||
exposure: round(spend),
|
||||
severity: due <= 30 ? "high" : "medium",
|
||||
dueInDays: Math.max(due, 0),
|
||||
detail: `Coverage lapses in ${Math.max(due, 0)} days on a vendor carrying $${round(spend).toLocaleString()} of billed work. Assignments cannot be placed against a lapsed certificate.`,
|
||||
action: "Request renewal",
|
||||
route: "/VendorCompliance",
|
||||
});
|
||||
}
|
||||
|
||||
/* A contracted rate about to expire is the discount about to expire. */
|
||||
const bySpendRole = new Map();
|
||||
for (const a of assignments) {
|
||||
if (!a.role) continue;
|
||||
bySpendRole.set(a.role, num(bySpendRole.get(a.role)) + num(a.total_bill));
|
||||
}
|
||||
const expiring = rates
|
||||
.filter((r) => r.is_approved_rate)
|
||||
.map((r) => ({ rate: r, due: daysUntil(r.valid_until) }))
|
||||
.filter((r) => r.due != null && r.due <= 60 && num(bySpendRole.get(r.rate.role_name)) > 0);
|
||||
if (expiring.length) {
|
||||
const soonest = expiring.sort((a, b) => a.due - b.due)[0];
|
||||
const exposure = expiring.reduce(
|
||||
(n, r) => n + num(bySpendRole.get(r.rate.role_name)) * (num(metrics.potentialSavingsPercent) / 100), 0
|
||||
);
|
||||
risks.push({
|
||||
id: "risk-rate-expiry",
|
||||
title: `${expiring.length} contracted rate${expiring.length === 1 ? "" : "s"} expiring`,
|
||||
exposure: round(exposure),
|
||||
severity: soonest.due <= 21 ? "high" : "medium",
|
||||
dueInDays: Math.max(soonest.due, 0),
|
||||
detail: `${soonest.rate.role_name} expires first, in ${Math.max(soonest.due, 0)} days. Work billed against an expired rate reverts to spot pricing.`,
|
||||
action: "Renew rate book",
|
||||
route: "/VendorRates",
|
||||
});
|
||||
}
|
||||
|
||||
/* Workers whose reliability is dragging the fill rate the savings rest on. */
|
||||
const weak = workforce.filter((w) => num(w.total_assignments) > 0 && num(w.reliability_index) < 70);
|
||||
if (weak.length) {
|
||||
const exposure = num(metrics.totalSpend) * (num(metrics.noShowRate) / 100);
|
||||
risks.push({
|
||||
id: "risk-reliability",
|
||||
title: `${weak.length} worker${weak.length === 1 ? "" : "s"} below reliability threshold`,
|
||||
exposure: round(exposure),
|
||||
severity: weak.length > 5 ? "high" : "medium",
|
||||
dueInDays: 0,
|
||||
detail: `${weak.length} of ${workforce.length} workers score under 70 on reliability while holding live assignments. Premium cover for their gaps is what the no-show leakage above is made of.`,
|
||||
action: "Review coverage",
|
||||
route: "/StaffDirectory",
|
||||
});
|
||||
}
|
||||
|
||||
return risks.sort((a, b) => b.exposure - a.exposure);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- series & feeds */
|
||||
|
||||
/** Monthly savings run-rate, from the dates the assignments actually carry. */
|
||||
function deriveTrend(assignments, metrics) {
|
||||
const buckets = new Map();
|
||||
for (const a of assignments) {
|
||||
const d = asDate(a.scheduled_start);
|
||||
if (!d) continue;
|
||||
const key = monthKey(d);
|
||||
const entry = buckets.get(key) || { key, label: monthLabel(d), spend: 0, contracted: 0 };
|
||||
entry.spend += num(a.total_bill);
|
||||
if (a.vendor_id) entry.contracted += num(a.total_bill);
|
||||
buckets.set(key, entry);
|
||||
}
|
||||
|
||||
const pctRate = num(metrics.potentialSavingsPercent) / 100;
|
||||
return [...buckets.values()]
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
.slice(-6)
|
||||
.map((b) => ({
|
||||
label: b.label,
|
||||
value: round((b.spend - b.contracted) * pctRate),
|
||||
spend: round(b.spend),
|
||||
contracted: round(b.contracted),
|
||||
}));
|
||||
}
|
||||
|
||||
/** What the workspace has done lately, read from the records themselves. */
|
||||
function deriveActivity(assignments, orders) {
|
||||
const events = [];
|
||||
|
||||
for (const a of assignments) {
|
||||
const when = a.updated_date || a.created_date || a.scheduled_start;
|
||||
const time = relativeTime(when);
|
||||
if (!time) continue;
|
||||
events.push({
|
||||
at: asDate(when).getTime(),
|
||||
time,
|
||||
label: `${a.role || "Assignment"} ${String(a.assignment_status || "updated").toLowerCase()}`,
|
||||
caption: [a.vendor_name, num(a.total_bill) ? `$${round(a.total_bill).toLocaleString()}` : null]
|
||||
.filter(Boolean).join(" · "),
|
||||
tone: a.assignment_status === "Completed" ? "positive"
|
||||
: a.assignment_status === "Canceled" ? "risk" : "neutral",
|
||||
});
|
||||
}
|
||||
|
||||
for (const o of orders) {
|
||||
const when = o.updated_date || o.created_date || o.date;
|
||||
const time = relativeTime(when);
|
||||
if (!time) continue;
|
||||
events.push({
|
||||
at: asDate(when).getTime(),
|
||||
time,
|
||||
label: `Order ${String(o.order_status || o.status || "updated").toLowerCase()}`,
|
||||
caption: o.event_name || o.order_number || o.id,
|
||||
tone: o.order_status === "Completed" ? "positive" : "neutral",
|
||||
});
|
||||
}
|
||||
|
||||
/* `at` is the sort key only — it never reaches a block. */
|
||||
return events
|
||||
.sort((a, b) => b.at - a.at)
|
||||
.slice(0, 6)
|
||||
.map((e) => ({ time: e.time, label: e.label, caption: e.caption, tone: e.tone }));
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- pipeline */
|
||||
|
||||
/**
|
||||
* How the engine reaches a number, with this workspace's own figures on each
|
||||
* step. The stages are the engine's; the counts are the page's.
|
||||
*/
|
||||
function derivePipeline({ metrics, opportunities, risks, collections }) {
|
||||
const money = (v) => `$${round(v).toLocaleString()}`;
|
||||
const compact = (v) => `$${(round(v) / 1000).toFixed(1)}K`;
|
||||
const sources = Object.entries(collections).filter(([, list]) => list.length);
|
||||
const automatable = opportunities.filter((o) => o.effort === "low");
|
||||
const approvals = opportunities.filter((o) => o.annual >= 10000);
|
||||
const avgConfidence = opportunities.length
|
||||
? round(opportunities.reduce((n, o) => n + o.confidence, 0) / opportunities.length) : 0;
|
||||
const avgWeeks = opportunities.length
|
||||
? (opportunities.reduce((n, o) => n + o.weeksToValue, 0) / opportunities.length).toFixed(1) : "0";
|
||||
|
||||
return [
|
||||
{
|
||||
id: "sources", label: "Data sources", caption: sources.map(([name]) => name).join(", ") || "none connected",
|
||||
tone: sources.length ? "neutral" : "warning",
|
||||
detail: {
|
||||
title: "Data sources",
|
||||
lines: [`Owliver reads ${sources.length} connected collection${sources.length === 1 ? "" : "s"} on this page.`],
|
||||
metrics: sources.map(([name, list]) => ({ label: name, value: String(list.length) })),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "spend", label: "Identify spend", caption: `${money(metrics.totalSpend)} billed`, tone: "neutral",
|
||||
detail: {
|
||||
title: "Spend identification",
|
||||
lines: [`Every assignment line is matched to a vendor and a role before it can be scored.`],
|
||||
metrics: [
|
||||
{ label: "Billed", value: money(metrics.totalSpend) },
|
||||
{ label: "Under contract", value: `${round(metrics.contractedRatio)}%` },
|
||||
{ label: "Assignments", value: String(collections.assignments.length) },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "detect", label: "Detect opportunity", caption: `${opportunities.length} found`,
|
||||
tone: opportunities.length ? "info" : "neutral",
|
||||
detail: {
|
||||
title: "Opportunity detection",
|
||||
lines: opportunities.length
|
||||
? [`${opportunities.length} opportunities found across the records this page carries.`]
|
||||
: ["Nothing in this workspace currently clears the detection threshold."],
|
||||
metrics: ["high", "medium", "low"].map((impact) => ({
|
||||
label: impact === "high" ? "High impact" : impact === "medium" ? "Medium" : "Low",
|
||||
value: String(opportunities.filter((o) => o.impact === impact).length),
|
||||
})),
|
||||
route: "/SavingsEngine", actionLabel: "View opportunities",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "calculate", label: "Calculate savings", caption: `${compact(metrics.potentialSavings * 12)} annualised`, tone: "info",
|
||||
detail: {
|
||||
title: "Savings calculation",
|
||||
lines: ["Each opportunity is modelled over twelve months of run rate and discounted by a confidence score."],
|
||||
metrics: [
|
||||
{ label: "Modelled", value: compact(metrics.potentialSavings * 12) },
|
||||
{ label: "Avg confidence", value: `${avgConfidence}%` },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "recommend", label: "Create recommendation", caption: `avg ${avgWeeks} weeks to value`, tone: "info",
|
||||
detail: {
|
||||
title: "Recommendation",
|
||||
lines: ["Each opportunity is routed to the team that owns the change, with an effort estimate."],
|
||||
metrics: [...new Set(opportunities.map((o) => o.owner))].map((owner) => ({
|
||||
label: owner, value: String(opportunities.filter((o) => o.owner === owner).length),
|
||||
})),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "approval", label: "Approval", caption: `${approvals.length} above $10K`,
|
||||
tone: approvals.length ? "warning" : "neutral",
|
||||
detail: {
|
||||
title: "Approval",
|
||||
lines: ["Anything above $10K annualised needs finance sign-off before it can run unattended."],
|
||||
metrics: [
|
||||
{ label: "Awaiting", value: String(approvals.length) },
|
||||
{ label: "Auto-eligible", value: String(opportunities.length - approvals.length) },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "automation", label: "Automation", caption: `${automatable.length} can run unattended`,
|
||||
tone: automatable.length ? "positive" : "neutral",
|
||||
detail: {
|
||||
title: "Automation",
|
||||
lines: ["A low-effort opportunity needs no renegotiation, so it becomes a standing rule that re-checks every cycle."],
|
||||
metrics: [
|
||||
{ label: "Low effort", value: String(automatable.length) },
|
||||
{ label: "Needs negotiation", value: String(opportunities.length - automatable.length) },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "captured", label: "Savings captured", caption: `${money(metrics.contractedSpend)} under contract`, tone: "positive",
|
||||
detail: {
|
||||
title: "Savings captured",
|
||||
lines: [`${money(metrics.contractedSpend)} of billed spend already runs at contracted rates — that is the saving the workspace is holding.`],
|
||||
metrics: [
|
||||
{ label: "Under contract", value: money(metrics.contractedSpend) },
|
||||
{ label: "At risk", value: money(risks.reduce((n, r) => n + r.exposure, 0)) },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/* =========================================================== the workspace */
|
||||
|
||||
/**
|
||||
* The Saving Engine page, as the object Owliver answers from.
|
||||
*
|
||||
* `metrics` and `projections` are taken from the page when it publishes them —
|
||||
* the page computed them to draw its own cards, and reusing them is what makes
|
||||
* the panel and the cards one reading. Everything else is derived here from the
|
||||
* records the page already loaded.
|
||||
*/
|
||||
export function buildSavingsWorkspace(page = {}) {
|
||||
const {
|
||||
assignments = [], vendors = [], workforce = [], orders = [], rates = [],
|
||||
activeTab = "overview", timeRange = "30days", userRole = "admin",
|
||||
} = page || {};
|
||||
|
||||
const collections = { assignments, vendors, workforce, orders, rates };
|
||||
const metrics = page.metrics || deriveMetrics(collections);
|
||||
const projections = page.projections || deriveProjections(metrics);
|
||||
|
||||
const opportunities = deriveOpportunities({ assignments, vendors, rates, metrics });
|
||||
const risks = deriveRisks({ assignments, vendors, workforce, rates, metrics });
|
||||
const trend = deriveTrend(assignments, metrics);
|
||||
const recentActivity = deriveActivity(assignments, orders);
|
||||
|
||||
const byCategory = [...opportunities.reduce((acc, o) => {
|
||||
acc.set(o.category, num(acc.get(o.category)) + o.annual);
|
||||
return acc;
|
||||
}, new Map())].map(([label, value]) => ({ label, value })).sort((a, b) => b.value - a.value);
|
||||
|
||||
const atRisk = risks.reduce((n, r) => n + r.exposure, 0);
|
||||
const projected = projectedFor(projections, timeRange);
|
||||
/* The previous window, only where the dated records actually cover one. A
|
||||
trend claim with nothing behind it is worse than no trend claim. */
|
||||
const previousPeriodSavings = trend.length >= 2 ? trend[trend.length - 2].value : null;
|
||||
|
||||
return {
|
||||
view: {
|
||||
activeTab, timeRange, userRole,
|
||||
timeLabel: TIME_LABEL[timeRange] || "30-day",
|
||||
},
|
||||
|
||||
totals: {
|
||||
/* The headline the page's own cards show for the selected range. */
|
||||
totalSavings: round(projected),
|
||||
previousPeriodSavings,
|
||||
capturedThisMonth: trend.length ? trend[trend.length - 1].value : 0,
|
||||
monthlyPotential: round(projections.thirtyDays),
|
||||
annualisedPotential: round(projections.year),
|
||||
opportunities: opportunities.length,
|
||||
/* A rule is a detection this workspace's records support; an automation is
|
||||
one that needs no renegotiation to run. Both are counted, never named. */
|
||||
activeRules: opportunities.length,
|
||||
automations: opportunities.filter((o) => o.effort === "low").length,
|
||||
atRisk,
|
||||
recentActivity: recentActivity.length,
|
||||
|
||||
totalSpend: round(metrics.totalSpend),
|
||||
contractedSpend: round(metrics.contractedSpend),
|
||||
nonContractedSpend: round(metrics.nonContractedSpend),
|
||||
contractedRatio: metrics.contractedRatio,
|
||||
potentialSavingsPercent: metrics.potentialSavingsPercent,
|
||||
avgContractedRate: metrics.avgContractedRate,
|
||||
avgNonContractedRate: metrics.avgNonContractedRate,
|
||||
rateDelta: metrics.avgNonContractedRate - metrics.avgContractedRate,
|
||||
fillRate: metrics.fillRate,
|
||||
avgReliability: metrics.avgReliability,
|
||||
noShowRate: metrics.noShowRate,
|
||||
activeVendors: metrics.activeVendors,
|
||||
totalWorkforce: metrics.totalWorkforce,
|
||||
completedOrders: metrics.completedOrders,
|
||||
assignments: assignments.length,
|
||||
},
|
||||
|
||||
opportunities,
|
||||
risks,
|
||||
trend,
|
||||
byCategory,
|
||||
recentActivity,
|
||||
pipeline: derivePipeline({ metrics, opportunities, risks, collections }),
|
||||
};
|
||||
}
|
||||
|
||||
export default buildSavingsWorkspace;
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* Order Management — local demo fixture
|
||||
* -------------------------------------
|
||||
* Nine orders in the real `Event` shape (src/entities/Event.json), used to
|
||||
* demonstrate the compact Owliver panel on /events without touching the page's
|
||||
* own data pipeline.
|
||||
*
|
||||
* Nothing here is a count. Every number Owliver quotes is derived from these
|
||||
* objects by PAGE_CONTEXTS.orders.summarise(), so the suggestions and answers
|
||||
* stay honest about the data in front of them — and swapping to live orders is
|
||||
* the one-line change at USE_DEMO_ORDERS below.
|
||||
*
|
||||
* Dates are anchored to DEMO_TODAY rather than `new Date()`: a fixture that
|
||||
* drifts with the wall clock silently changes its own upcoming/past split and
|
||||
* stops matching what the page shows.
|
||||
*/
|
||||
|
||||
/** The "today" this fixture is written around. */
|
||||
export const DEMO_TODAY = "2026-08-17";
|
||||
|
||||
/**
|
||||
* Flip to false to point Owliver at the page's live `eventsWithConflicts`
|
||||
* instead (see the registration call in src/pages/Events.jsx).
|
||||
*/
|
||||
export const USE_DEMO_ORDERS = true;
|
||||
|
||||
/** One shift with a single role — enough for the seat maths in summarise(). */
|
||||
const shift = (start, end, role, count) => ({
|
||||
location: "Main Floor",
|
||||
roles: [{ role, department: "Operations", count, start_time: start, end_time: end }],
|
||||
});
|
||||
|
||||
/** `count` placeholder assignments, so assigned_staff.length is the fill. */
|
||||
const filled = (count) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
staff_id: `demo-staff-${i + 1}`,
|
||||
staff_name: `Assigned Worker ${i + 1}`,
|
||||
role: "Crew",
|
||||
}));
|
||||
|
||||
/**
|
||||
* Derived tallies, for reference — all computed by summarise(), none stored:
|
||||
* total 9 · rapid 1 · requested (Pending) 1 · partially staffed 3
|
||||
* fully staffed 1 · active 1 · past (Completed) 2 · draft 1
|
||||
* conflicts 0 · upcoming (date > DEMO_TODAY) 5
|
||||
*
|
||||
* Staffing status is assigned_staff.length vs the sum of shifts[].roles[].count,
|
||||
* exactly as Events.jsx computes it. Only the orders being worked right now
|
||||
* carry a roster here: draft, pending, active and closed-out orders are left
|
||||
* with an empty assigned_staff, which is what keeps "fully staffed" at 1 rather
|
||||
* than sweeping in the two completed orders.
|
||||
*/
|
||||
export const DEMO_ORDERS = [
|
||||
{
|
||||
id: "demo-order-1",
|
||||
event_name: "Vendor Fair — Facilities Support",
|
||||
business_name: "Northbay Tech Campus",
|
||||
hub: "Northbay HQ",
|
||||
date: "2026-08-31",
|
||||
status: "Draft",
|
||||
is_rapid: false,
|
||||
shifts: [shift("07:00", "15:00", "Facilities Attendant", 8)],
|
||||
assigned_staff: [],
|
||||
detected_conflicts: [],
|
||||
},
|
||||
{
|
||||
id: "demo-order-2",
|
||||
event_name: "Freshman Move-In Dining Surge",
|
||||
business_name: "Peninsula University Dining",
|
||||
hub: "Peninsula Commons",
|
||||
date: "2026-08-26",
|
||||
status: "Pending",
|
||||
is_rapid: false,
|
||||
shifts: [shift("06:30", "14:30", "Dining Attendant", 14)],
|
||||
assigned_staff: [],
|
||||
detected_conflicts: [],
|
||||
},
|
||||
{
|
||||
id: "demo-order-3",
|
||||
event_name: "Quarterly Partner Summit",
|
||||
business_name: "Harbor Point Conference Center",
|
||||
hub: "Harbor Point",
|
||||
date: "2026-08-24",
|
||||
status: "Partial Staffed",
|
||||
is_rapid: false,
|
||||
shifts: [shift("08:00", "17:00", "Event Server", 12)],
|
||||
assigned_staff: filled(5),
|
||||
detected_conflicts: [],
|
||||
},
|
||||
{
|
||||
id: "demo-order-4",
|
||||
event_name: "Stadium Concessions — Season Opener",
|
||||
business_name: "Riverside Sports Group",
|
||||
hub: "Riverside Arena",
|
||||
date: "2026-08-21",
|
||||
status: "Partial Staffed",
|
||||
is_rapid: true,
|
||||
shifts: [shift("14:00", "23:00", "Concessions Crew", 20)],
|
||||
assigned_staff: filled(9),
|
||||
detected_conflicts: [],
|
||||
},
|
||||
{
|
||||
id: "demo-order-5",
|
||||
event_name: "Warehouse Inventory Count",
|
||||
business_name: "Cedar Grove Logistics",
|
||||
hub: "Cedar Grove DC",
|
||||
date: "2026-08-19",
|
||||
status: "Fully Staffed",
|
||||
is_rapid: false,
|
||||
shifts: [shift("05:00", "13:00", "Warehouse Associate", 6)],
|
||||
assigned_staff: filled(6),
|
||||
detected_conflicts: [],
|
||||
},
|
||||
{
|
||||
id: "demo-order-6",
|
||||
event_name: "Executive Briefing Catering",
|
||||
business_name: "Lakeshore Financial",
|
||||
hub: "Lakeshore Tower",
|
||||
date: "2026-08-17",
|
||||
status: "Active",
|
||||
is_rapid: false,
|
||||
shifts: [shift("09:00", "16:00", "Catering Lead", 4)],
|
||||
assigned_staff: [],
|
||||
detected_conflicts: [],
|
||||
},
|
||||
{
|
||||
id: "demo-order-7",
|
||||
event_name: "Retail Floor Reset",
|
||||
business_name: "Fairview Retail Group",
|
||||
hub: "Fairview Center",
|
||||
date: "2026-08-17",
|
||||
status: "Partial Staffed",
|
||||
is_rapid: false,
|
||||
shifts: [shift("18:00", "23:30", "Merchandiser", 10)],
|
||||
assigned_staff: filled(3),
|
||||
detected_conflicts: [],
|
||||
},
|
||||
{
|
||||
id: "demo-order-8",
|
||||
event_name: "Alumni Weekend Banquet",
|
||||
business_name: "Peninsula University Dining",
|
||||
hub: "Peninsula Commons",
|
||||
date: "2026-08-08",
|
||||
status: "Completed",
|
||||
is_rapid: false,
|
||||
shifts: [shift("15:00", "22:00", "Banquet Server", 18)],
|
||||
assigned_staff: [],
|
||||
detected_conflicts: [],
|
||||
},
|
||||
{
|
||||
id: "demo-order-9",
|
||||
event_name: "Data Center Maintenance Window",
|
||||
business_name: "Northbay Tech Campus",
|
||||
hub: "Northbay HQ",
|
||||
date: "2026-08-02",
|
||||
status: "Completed",
|
||||
is_rapid: false,
|
||||
shifts: [shift("22:00", "06:00", "Facilities Technician", 5)],
|
||||
assigned_staff: [],
|
||||
detected_conflicts: [],
|
||||
},
|
||||
];
|
||||
|
||||
export default DEMO_ORDERS;
|
||||
@@ -539,8 +539,10 @@ const generic = (ctx) => {
|
||||
* @param {Array} args.conversation the thread *before* this prompt
|
||||
* @returns {Promise<string>} markdown answer — never throws, never empty
|
||||
*/
|
||||
export async function getOwliverResponse({ prompt, pageContext, conversation } = {}) {
|
||||
await wait(LATENCY_MIN_MS + Math.random() * (LATENCY_MAX_MS - LATENCY_MIN_MS));
|
||||
export async function getOwliverResponse({ prompt, pageContext, conversation, skipLatency } = {}) {
|
||||
// The panel now stages its own thinking line, so a caller that is already
|
||||
// pacing the turn asks for the answer without the deliberation beat.
|
||||
if (!skipLatency) await wait(LATENCY_MIN_MS + Math.random() * (LATENCY_MAX_MS - LATENCY_MIN_MS));
|
||||
|
||||
const ctx = {
|
||||
domain: pageContext?.domain || "command",
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
*/
|
||||
|
||||
import { krow } from "@/api/client";
|
||||
import { buildSavingsWorkspace } from "./knowledge/savingsWorkspace";
|
||||
import { buildAnalyticsWorkspace } from "./knowledge/analyticsWorkspace";
|
||||
|
||||
/* ------------------------------------------------------------------ utils */
|
||||
|
||||
@@ -399,38 +401,79 @@ export const DOMAINS = {
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Saving Engine.
|
||||
*
|
||||
* The route-level fallback for when the panel is opened before the page has
|
||||
* mounted and registered itself. It reads the same five collections the page
|
||||
* reads and runs the same derivation, so the answer is identical either way —
|
||||
* this is a second entry point to one reading, not a second reading.
|
||||
*/
|
||||
savingEngine: {
|
||||
key: "savingEngine",
|
||||
working: "Reading Saving Engine data…",
|
||||
placeholder: "Ask Owliver about Saving Engine...",
|
||||
title: "Saving Engine",
|
||||
entity: "Assignment",
|
||||
async loadSignals() {
|
||||
const [assignments, vendors, workforce, orders, rates] = await Promise.all([
|
||||
listSafe("Assignment"), listSafe("Vendor"), listSafe("Workforce"),
|
||||
listSafe("Order"), listSafe("VendorRate"),
|
||||
]);
|
||||
return buildSavingsWorkspace({ assignments, vendors, workforce, orders, rates });
|
||||
},
|
||||
buildInsight(d) {
|
||||
const t = d?.totals || {};
|
||||
return {
|
||||
headline: `${plural(t.opportunities || 0, "savings opportunity", "savings opportunities")} detected on this workspace.`,
|
||||
supporting: `Reading ${plural(t.assignments || 0, "assignment", "assignments")} — $${Math.round((t.monthlyPotential || 0) / 1000)}K of monthly potential against $${Math.round((t.totalSpend || 0) / 1000)}K of billed spend.`,
|
||||
};
|
||||
},
|
||||
buildSuggestions(d) {
|
||||
const t = d?.totals || {};
|
||||
return [
|
||||
s("Give me a summary", "Give me a summary", 0),
|
||||
t.opportunities > 0 && s("Show my biggest opportunities", "Show my biggest savings opportunities", 0),
|
||||
t.atRisk > 0 && s("What savings are at risk?", "What savings are at risk?", 1),
|
||||
s("Where are we saving the most?", "Where are we saving the most?", 1),
|
||||
s("Explain the savings trend", "Explain the savings trend", 2),
|
||||
s("How does Saving Engine work?", "How does the Saving Engine work?", 3),
|
||||
].filter(Boolean);
|
||||
},
|
||||
},
|
||||
|
||||
analytics: {
|
||||
key: "analytics",
|
||||
working: "Reading the numbers…",
|
||||
placeholder: "Ask Owliver about performance trends...",
|
||||
title: "Operational Intelligence",
|
||||
entity: "Event",
|
||||
/* The route-level fallback: the same two collections the Reports page
|
||||
reads, through the same derivation, so a question answered before the
|
||||
page mounts and one answered after it cannot disagree. */
|
||||
async loadSignals() {
|
||||
const [events, invoices] = await Promise.all([
|
||||
listSafe("Event", "-date", 250),
|
||||
listSafe("Invoice", "-issue_date", 250),
|
||||
]);
|
||||
const completed = events.filter((e) => e.status === "Completed");
|
||||
return {
|
||||
events: events.length,
|
||||
completed: completed.length,
|
||||
revenue: invoices.reduce((n, i) => n + (i.amount || 0), 0),
|
||||
invoices: invoices.length,
|
||||
};
|
||||
return buildAnalyticsWorkspace({ events, invoices });
|
||||
},
|
||||
buildInsight(d) {
|
||||
const t = d?.totals || {};
|
||||
return {
|
||||
headline: `${plural(d.completed, "order", "orders")} completed out of ${d.events} tracked.`,
|
||||
supporting: `Reading ${plural(d.invoices, "invoice", "invoices")} and order history — trends, anomalies and what changed.`,
|
||||
headline: `${plural(t.completed || 0, "order", "orders")} completed out of ${t.events || 0} tracked.`,
|
||||
supporting: `Reading ${plural(t.invoices || 0, "invoice", "invoices")} and order history — trends, anomalies and what changed.`,
|
||||
};
|
||||
},
|
||||
buildSuggestions() {
|
||||
buildSuggestions(d) {
|
||||
const t = d?.totals || {};
|
||||
return [
|
||||
s("What changed this week?", "What changed this week compared to last week? Focus on anything unusual.", 0),
|
||||
s("What needs attention?", "What in the numbers needs my attention?", 1),
|
||||
s("Identify operational risks", "Identify the operational risks hiding in this data.", 2),
|
||||
s("Where is margin leaking?", "Where am I leaking margin, and what would fix it?", 3),
|
||||
];
|
||||
s("Give me a summary", "Give me a summary", 0),
|
||||
(d?.timeline?.length || 0) > 1 && s("What changed over the period?", "What changed over the period?", 1),
|
||||
(d?.byDept?.length || 0) > 0 && s("Which department costs most?", "Which department costs the most?", 1),
|
||||
(t.openShifts > 0 || t.fillRatePct < 95) && s("What needs attention?", "What in the numbers needs my attention?", 2),
|
||||
s("Where is margin leaking?", "Where is margin leaking?", 3),
|
||||
].filter(Boolean);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -576,7 +619,8 @@ const PAGE_DOMAIN = {
|
||||
Business: "network", AddBusiness: "network", EditBusiness: "network",
|
||||
|
||||
// Analytics
|
||||
Reports: "analytics", SavingsEngine: "analytics",
|
||||
Reports: "analytics",
|
||||
SavingsEngine: "savingEngine",
|
||||
PredictiveIntelligence: "analytics", ActivityLog: "analytics",
|
||||
KrowEdge: "analytics", DemandSupply: "analytics",
|
||||
ClientLaborReport: "analytics", FeatureReport: "analytics",
|
||||
@@ -1266,3 +1310,96 @@ PAGE_CONTEXTS.orders = {
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------ Saving Engine */
|
||||
|
||||
/**
|
||||
* The Saving Engine page's own records, published straight from the page.
|
||||
*
|
||||
* `summarise` does nothing but hand the page's data to the shared derivation —
|
||||
* the same one `DOMAINS.savingEngine.loadSignals()` runs — so the panel and the
|
||||
* cards on the page are two readings of one computation. `metrics` and
|
||||
* `projections` come across because the page already computed them to draw its
|
||||
* own cards; reusing them is what stops the two from drifting apart.
|
||||
*/
|
||||
PAGE_CONTEXTS.savingEngine = {
|
||||
key: "savingEngine",
|
||||
title: "Saving Engine",
|
||||
entity: "Assignment",
|
||||
placeholder: "Ask Owliver about Saving Engine...",
|
||||
working: "Reading Saving Engine data",
|
||||
|
||||
summarise: (data = {}) => buildSavingsWorkspace(data || {}),
|
||||
|
||||
buildInsight(d) {
|
||||
const t = d?.totals || {};
|
||||
const lead = d?.opportunities?.[0];
|
||||
return {
|
||||
headline: lead
|
||||
? `${lead.title} is the largest savings opportunity on this page.`
|
||||
: `${plural(Math.round(t.contractedRatio || 0), "percent", "percent")} of billed spend is already under contract.`,
|
||||
supporting: `Reading ${plural(t.assignments || 0, "assignment", "assignments")} across ${plural(t.activeVendors || 0, "active vendor", "active vendors")} — $${Math.round((t.monthlyPotential || 0) / 1000)}K of monthly potential${t.atRisk ? `, $${Math.round(t.atRisk / 1000)}K at risk` : ""}.`,
|
||||
};
|
||||
},
|
||||
|
||||
buildSuggestions(d) {
|
||||
const t = d?.totals || {};
|
||||
const lead = d?.opportunities?.[0];
|
||||
return [
|
||||
s("Give me a summary", "Give me a summary", 0),
|
||||
t.opportunities > 0 && s("Show my biggest opportunities", "Show my biggest savings opportunities", 0),
|
||||
t.atRisk > 0 && s("What savings are at risk?", "What savings are at risk?", 1),
|
||||
(d?.byCategory?.length || 0) > 1 && s("Where are we saving the most?", "Where are we saving the most?", 1),
|
||||
lead && s(`Why is ${lead.title.toLowerCase()} #1?`, `Why is ${lead.title} the number one saving?`, 2),
|
||||
(d?.trend?.length || 0) > 1 && s("Explain the savings trend", "Explain the savings trend", 2),
|
||||
s("How does Saving Engine work?", "How does the Saving Engine work?", 3),
|
||||
s("Show the important numbers", "Show the important numbers", 4),
|
||||
].filter(Boolean);
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- Analytics */
|
||||
|
||||
/**
|
||||
* The Reports page's own figures, published from the report itself rather than
|
||||
* from the route.
|
||||
*
|
||||
* Registering inside `ClientTrendsReport` rather than in `Reports.jsx` is what
|
||||
* gives Owliver the period the reader has selected: the same records under a
|
||||
* 7-day and an all-time filter are two different pages, and answering about the
|
||||
* wrong one is the mistake this channel exists to prevent.
|
||||
*/
|
||||
PAGE_CONTEXTS.analytics = {
|
||||
key: "analytics",
|
||||
title: "Operational Intelligence",
|
||||
entity: "Event",
|
||||
placeholder: "Ask Owliver about trends, metrics, or performance...",
|
||||
working: "Reading the numbers",
|
||||
|
||||
summarise: (data = {}) => buildAnalyticsWorkspace(data || {}),
|
||||
|
||||
buildInsight(d) {
|
||||
const t = d?.totals || {};
|
||||
const lead = d?.byDept?.[0] || d?.byHub?.[0];
|
||||
return {
|
||||
headline: lead
|
||||
? `${lead.name} is the largest cost centre on this report.`
|
||||
: `${plural(t.completed || 0, "order", "orders")} completed out of ${t.events || 0} tracked.`,
|
||||
supporting: `Reading ${plural(t.events || 0, "order", "orders")} and ${plural(t.invoices || 0, "invoice", "invoices")} over the ${d?.view?.periodLabel || "tracked window"} — $${Math.round((t.spend || 0) / 1000)}K of labor spend at a ${Math.round(t.fillRatePct || 0)}% fill rate.`,
|
||||
};
|
||||
},
|
||||
|
||||
buildSuggestions(d) {
|
||||
const t = d?.totals || {};
|
||||
const dept = d?.byDept?.[0];
|
||||
return [
|
||||
s("Give me a summary", "Give me a summary", 0),
|
||||
(d?.timeline?.length || 0) > 1 && s("What changed over the period?", "What changed over the period?", 1),
|
||||
dept && s(`Why is ${dept.name} the biggest cost?`, "Which department costs the most?", 1),
|
||||
(d?.byPosition?.length || 0) > 0 && s("Which position costs most?", "Which position costs the most?", 2),
|
||||
(t.openShifts > 0 || t.fillRatePct < 95) && s("What needs attention?", "What in the numbers needs my attention?", 2),
|
||||
s("Where is margin leaking?", "Where is margin leaking?", 3),
|
||||
t.scheduledHours > 0 && s("Scheduled vs worked hours", "Show scheduled against worked hours", 4),
|
||||
].filter(Boolean);
|
||||
},
|
||||
};
|
||||
|
||||
75
src/lib/owliver/responseEngine.js
Normal file
75
src/lib/owliver/responseEngine.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Owliver response engine
|
||||
* -----------------------
|
||||
* One entry point for one turn. It resolves the knowledge pack for the page the
|
||||
* user is actually on, hands it the question plus the thread's state, and
|
||||
* returns a staged answer:
|
||||
*
|
||||
* { topic, stages: string[], blocks: OwliverBlock[], followUps: [] }
|
||||
*
|
||||
* `stages` are the lines shown while the answer is being composed ("Checking 27
|
||||
* opportunities…"), `blocks` are the answer itself and `followUps` are the
|
||||
* questions this particular answer makes worth asking next. The provider owns
|
||||
* the pacing; this module owns the content.
|
||||
*
|
||||
* Nothing about the page's data is fetched here — `signals` is whatever the page
|
||||
* already published. When a real Saving Engine API or model response arrives, it
|
||||
* enters at exactly this seam: a pack whose `answer()` awaits a network call and
|
||||
* returns the same block array.
|
||||
*/
|
||||
|
||||
import getKnowledge from "./knowledge";
|
||||
import { blocks, text, actions } from "./blocks";
|
||||
|
||||
/** The last thing Owliver answered, so a pronoun has something to bind to. */
|
||||
export const lastTopicOf = (conversation = []) => {
|
||||
for (let i = conversation.length - 1; i >= 0; i -= 1) {
|
||||
const m = conversation[i];
|
||||
if (m.role === "assistant" && m.topic) return m.topic;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const errorAnswer = () => ({
|
||||
topic: null,
|
||||
stages: [],
|
||||
error: true,
|
||||
blocks: blocks(
|
||||
text("Something went wrong while analyzing this workspace."),
|
||||
actions([{ label: "Try again", retry: true }])
|
||||
),
|
||||
followUps: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* @returns {Promise<{topic, stages, blocks, followUps, error?}>}
|
||||
*/
|
||||
export async function getOwliverAnswer({
|
||||
prompt, domainKey, domain, signals, pageContext, conversation = [],
|
||||
} = {}) {
|
||||
const pack = getKnowledge(domainKey, domain);
|
||||
|
||||
try {
|
||||
const answer = await pack.answer({
|
||||
prompt,
|
||||
signals,
|
||||
pageContext,
|
||||
conversation,
|
||||
lastTopic: lastTopicOf(conversation),
|
||||
});
|
||||
|
||||
if (!answer || !answer.blocks?.length) return errorAnswer();
|
||||
|
||||
return {
|
||||
topic: answer.topic ?? null,
|
||||
stages: answer.stages || [],
|
||||
blocks: answer.blocks,
|
||||
followUps: answer.followUps || [],
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Owliver: answer failed", err);
|
||||
return errorAnswer();
|
||||
}
|
||||
}
|
||||
|
||||
export default getOwliverAnswer;
|
||||
@@ -2,11 +2,6 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from '@/App.jsx'
|
||||
import '@/index.css'
|
||||
// After index.css on purpose. Astryx's atomic classes and Tailwind's preflight
|
||||
// have the same specificity, so whichever loads last wins; with Astryx first,
|
||||
// preflight's `button { padding: 0; background: transparent }` flattened every
|
||||
// Astryx control. Import order here is the load order.
|
||||
import '@astryxdesign/core/astryx.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
// <React.StrictMode>
|
||||
|
||||
1133
src/pages/Events.jsx
1133
src/pages/Events.jsx
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import {
|
||||
Building2, Briefcase, Package, RefreshCw, Download, Filter, Wallet, Brain
|
||||
} from "lucide-react";
|
||||
import PageHeader from "../components/common/PageHeader";
|
||||
import useOwliverPageContext from "@/components/owliver/useOwliverPageContext";
|
||||
import SavingsOverviewCards from "../components/savings/SavingsOverviewCards";
|
||||
import ContractConversionMap from "../components/savings/ContractConversionMap";
|
||||
import PredictiveSavingsModel from "../components/savings/PredictiveSavingsModel";
|
||||
@@ -193,6 +194,18 @@ export default function SavingsEngine() {
|
||||
|
||||
const roleTabs = getRoleTabs();
|
||||
|
||||
// Owliver answers this page from the records this page has already loaded.
|
||||
// `metrics` and `projections` are the same objects the cards below render
|
||||
// from, so the panel and the page cannot quote different figures.
|
||||
useOwliverPageContext("savingEngine", {
|
||||
assignments, vendors, workforce, orders, rates,
|
||||
metrics, projections, activeTab, timeRange, userRole,
|
||||
// A primitive the registration signature can see: `metrics` is an object,
|
||||
// and re-registering only when a collection's length changes would leave
|
||||
// Owliver holding a stale reading after an in-place record update.
|
||||
spend: Math.round(metrics.totalSpend),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20 min-h-screen">
|
||||
<div className="max-w-[1800px] mx-auto">
|
||||
|
||||
Reference in New Issue
Block a user