diff --git a/apps/apollo-vertex/app/_meta.ts b/apps/apollo-vertex/app/_meta.ts index 96c326ad0..8ad97f65a 100644 --- a/apps/apollo-vertex/app/_meta.ts +++ b/apps/apollo-vertex/app/_meta.ts @@ -3,6 +3,7 @@ export default { dashboards: "Dashboards", "data-querying": "Data Querying", patterns: "Patterns", + layouts: "Layouts", "shadcn-components": "Shadcn Components", "vertex-components": "Vertex Components", themes: "Themes", diff --git a/apps/apollo-vertex/app/globals.css b/apps/apollo-vertex/app/globals.css index ee69bfa7f..6db7e92ac 100644 --- a/apps/apollo-vertex/app/globals.css +++ b/apps/apollo-vertex/app/globals.css @@ -1,4 +1,6 @@ @import 'tailwindcss'; +@source "../registry"; +@source "../templates"; /* Optional: import Nextra theme styles */ @import 'nextra-theme-docs/style.css'; diff --git a/apps/apollo-vertex/app/layouts/_meta.ts b/apps/apollo-vertex/app/layouts/_meta.ts new file mode 100644 index 000000000..c454dc3d1 --- /dev/null +++ b/apps/apollo-vertex/app/layouts/_meta.ts @@ -0,0 +1,3 @@ +export default { + dashboard: "Dashboard", +}; diff --git a/apps/apollo-vertex/app/layouts/dashboard/page.mdx b/apps/apollo-vertex/app/layouts/dashboard/page.mdx new file mode 100644 index 000000000..15e0fc487 --- /dev/null +++ b/apps/apollo-vertex/app/layouts/dashboard/page.mdx @@ -0,0 +1,149 @@ +import { DashboardTemplate } from '@/templates/dashboard/DashboardTemplateDynamic'; +import { PreviewFullScreen } from '@/app/components/preview-full-screen'; + +# Dashboard + +A configurable dashboard template for building AI-assisted operational views. The dashboard is composed of named card regions with defined interaction patterns, designed to be data-driven and adaptable across verticals. + +## Previews + +### Sidebar Shell + + + + + +Open standalone preview → + +### Minimal Header Shell + + + + + +Open standalone preview → + +--- + +## Anatomy + +The dashboard is built from four semantic regions, each with a distinct role: + +| Region | Description | +|---|---| +| **Overview Card** | The primary narrative area. Displays a greeting, headline, and subhead that summarize the current state. Occupies the top-left of the layout. | +| **Prompt Bar** | The AI input surface. Supports text entry, recommendation chips, and an expand interaction that opens an inline chat view. Anchored below the overview card. | +| **Insight Cards** | A grid of data cards on the right side. Each card has a type, a chart visualization, and an interaction pattern. Cards are arranged in rows of two. | +| **Autopilot Panel** | A slide-in panel that provides AI-generated analysis for a specific insight card. Triggered from the card's autopilot icon. | + +## Card Types + +Every insight card has a `type` that determines what it displays: + +| Type | Renders | Example | +|---|---|---| +| `kpi` | A large number, badge, and description | "97.1%" with "+2.4%" badge | +| `chart` | A data visualization determined by `chartType` | Horizontal bars, stacked bars | + +## Chart Types + +Cards with `type: "chart"` use a `chartType` to select the visualization: + +| Chart Type | Description | +|---|---| +| `horizontal-bars` | Ranked list of items with proportional bars and percentages | +| `stacked-bar` | Grouped bars with color-coded segments and a legend | +| `donut` | Circular progress indicator with a center label | +| `sparkline` | Compact line chart for trend indication | +| `area` | Filled area chart for volume over time | + +## Card Sizes + +Each card has a `size` that controls its column weight in the grid: + +| Size | Grid Weight | Use Case | +|---|---|---| +| `sm` | `1fr` | KPI cards, compact metrics | +| `md` | `2fr` | Chart cards, detailed visualizations | +| `lg` | `1fr` | Full-width cards | + +## Interaction Patterns + +Cards support three interaction modes: + +### Static + +No interactive behavior. The card displays its content without any hover or click affordance. Used for simple KPIs that don't need drill-down. + +### Navigate + +On hover, an **arrow-up-right** icon appears in the card header. Clicking the card navigates to a detail page. Used for KPIs or summaries that link to a deeper view. + +### Expand + +On hover, a **maximize** icon appears in the card header. Clicking expands the card to fill the grid with a multi-phase animation: + +1. **Width phase** — Card expands horizontally, sibling card collapses +2. **Height phase** — Card expands vertically, other rows collapse +3. **Full phase** — Expanded content fades in (drilldown tabs, autopilot prompts) + +Clicking the **minimize** icon or another card collapses back to the grid. + +For `horizontal-bars` cards, the expanded state includes **drilldown tabs** below the title for switching between data views. + +## Prompt Bar + +The prompt bar supports three ways to expand into the inline chat view: + +| Trigger | Behavior | +|---|---| +| **Type + Enter / Submit** | Expands and passes the user's typed query | +| **Click a recommendation chip** | Expands and passes the chip text as the query | +| **Click the chat icon** | Expands and shows the current session conversation | + +When expanded, the overview card collapses and the prompt bar grows to fill the left column. A **minimize** icon in the header collapses back to the default layout. + +## Autopilot Panel + +Each expandable card includes an **autopilot** icon alongside the expand icon. Clicking it slides in a panel from the right that provides AI-generated context for that card. The dashboard content shifts left to make room. Clicking the autopilot icon again or the close button dismisses the panel. + +## Data Configuration + +The dashboard is driven by a `DashboardDataset` object that defines all text and card content: + +```ts +interface DashboardDataset { + name: string; // Dataset identifier + brandName: string; // Company name in header + brandLine: string; // Tagline in header + dashboardTitle: string; // Page title + badgeText: string; // Badge next to title + greeting: string; // Overview card greeting + headline: string; // Overview card headline + subhead: string; // Overview card description + promptPlaceholder: string; // Prompt bar placeholder text + promptSuggestions: string[]; // Recommendation chips + insightCards: [ // Exactly 4 insight cards + InsightCardData, + InsightCardData, + InsightCardData, + InsightCardData, + ]; +} +``` + +Each `InsightCardData` specifies its title, type, chart type, size, interaction, and the data for its visualization (KPI values, bar data, stacked segments, etc.). + +## Layout Modes + +The dashboard responds to container width: + +| Mode | Breakpoint | Behavior | +|---|---|---| +| **Desktop** | ≥ 1100px | Two-column layout, full card grid | +| **Compact** | 800–1099px | Two-column layout, condensed card content | +| **Stacked** | < 800px | Single-column, vertically stacked | + +## Theming + +Card backgrounds, glow effects, and gradients are configurable through `CardConfig` and `GlowConfig` objects. The dashboard supports light and dark modes with independent styling for each. Color tokens and theme values should be updated in `registry.json`, not directly in CSS files. diff --git a/apps/apollo-vertex/app/preview/_meta.ts b/apps/apollo-vertex/app/preview/_meta.ts new file mode 100644 index 000000000..e48eafb8a --- /dev/null +++ b/apps/apollo-vertex/app/preview/_meta.ts @@ -0,0 +1,3 @@ +export default { + "*": { display: "hidden" }, +}; diff --git a/apps/apollo-vertex/app/preview/dashboard-minimal/page.tsx b/apps/apollo-vertex/app/preview/dashboard-minimal/page.tsx new file mode 100644 index 000000000..30acb999a --- /dev/null +++ b/apps/apollo-vertex/app/preview/dashboard-minimal/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const DashboardTemplate = dynamic( + () => import("@/templates/dashboard/DashboardTemplate").then((mod) => mod.DashboardTemplate), + { ssr: false }, +); + +export default function DashboardMinimalPreviewPage() { + return ( +
+ +
+ ); +} diff --git a/apps/apollo-vertex/app/preview/dashboard/page.tsx b/apps/apollo-vertex/app/preview/dashboard/page.tsx new file mode 100644 index 000000000..db71ea817 --- /dev/null +++ b/apps/apollo-vertex/app/preview/dashboard/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const DashboardTemplate = dynamic( + () => import("@/templates/dashboard/DashboardTemplate").then((mod) => mod.DashboardTemplate), + { ssr: false }, +); + +export default function DashboardPreviewPage() { + return ( +
+ +
+ ); +} diff --git a/apps/apollo-vertex/public/Autopilot_dark.svg b/apps/apollo-vertex/public/Autopilot_dark.svg new file mode 100644 index 000000000..5f35c08dc --- /dev/null +++ b/apps/apollo-vertex/public/Autopilot_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/apollo-vertex/public/Autopilot_light.svg b/apps/apollo-vertex/public/Autopilot_light.svg new file mode 100644 index 000000000..4f28c9a53 --- /dev/null +++ b/apps/apollo-vertex/public/Autopilot_light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index 09f7884fd..ba4087f26 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -64,7 +64,17 @@ "color-sidebar-accent": "var(--sidebar-accent)", "color-sidebar-accent-foreground": "var(--sidebar-accent-foreground)", "color-sidebar-border": "var(--sidebar-border)", - "color-sidebar-ring": "var(--sidebar-ring)" + "color-sidebar-ring": "var(--sidebar-ring)", + "color-insight-50": "var(--insight-50)", + "color-insight-100": "var(--insight-100)", + "color-insight-200": "var(--insight-200)", + "color-insight-300": "var(--insight-300)", + "color-insight-400": "var(--insight-400)", + "color-insight-500": "var(--insight-500)", + "color-insight-600": "var(--insight-600)", + "color-insight-700": "var(--insight-700)", + "color-insight-800": "var(--insight-800)", + "color-insight-900": "var(--insight-900)" }, "light": { "background": "oklch(1 0 89.8800)", @@ -118,6 +128,16 @@ "sidebar-accent-foreground": "oklch(0.1660 0.0283 203.3380)", "sidebar-border": "oklch(0.9237 0.0133 262.3780)", "sidebar-ring": "oklch(0.64 0.115 208)", + "insight-50": "oklch(0.96 0.03 277)", + "insight-100": "oklch(0.92 0.05 277)", + "insight-200": "oklch(0.86 0.09 277)", + "insight-300": "oklch(0.78 0.14 277)", + "insight-400": "oklch(0.70 0.19 277)", + "insight-500": "oklch(0.62 0.22 277)", + "insight-600": "oklch(0.56 0.20 277)", + "insight-700": "oklch(0.48 0.17 277)", + "insight-800": "oklch(0.38 0.13 278)", + "insight-900": "oklch(0.30 0.10 278)", "font-sans": "Inter, ui-sans-serif, sans-serif, system-ui", "font-serif": "IBM Plex Serif, ui-serif, serif", "font-mono": "IBM Plex Mono, ui-monospace, monospace", @@ -207,6 +227,16 @@ "sidebar-accent-foreground": "oklch(0.9525 0.0110 225.9830)", "sidebar-border": "oklch(0.9525 0.0110 225.9830)", "sidebar-ring": "oklch(0.69 0.112 207)", + "insight-50": "oklch(0.96 0.03 277)", + "insight-100": "oklch(0.92 0.05 277)", + "insight-200": "oklch(0.86 0.09 277)", + "insight-300": "oklch(0.78 0.14 277)", + "insight-400": "oklch(0.70 0.19 277)", + "insight-500": "oklch(0.62 0.22 277)", + "insight-600": "oklch(0.56 0.20 277)", + "insight-700": "oklch(0.48 0.17 277)", + "insight-800": "oklch(0.38 0.13 278)", + "insight-900": "oklch(0.30 0.10 278)", "font-sans": "Inter, ui-sans-serif, sans-serif, system-ui", "font-serif": "IBM Plex Serif, ui-serif, serif", "font-mono": "IBM Plex Mono, ui-monospace, monospace", diff --git a/apps/apollo-vertex/registry/card/card.tsx b/apps/apollo-vertex/registry/card/card.tsx index d9d31dfa0..23bd35901 100644 --- a/apps/apollo-vertex/registry/card/card.tsx +++ b/apps/apollo-vertex/registry/card/card.tsx @@ -10,7 +10,7 @@ export const GLASS_CLASSES = [ "dark:shadow-[0_2px_24px_2px_rgba(0,0,0,0.12),inset_0_1px_0_0_color-mix(in_srgb,var(--sidebar)_5%,transparent)]", ] as const; -const cardVariants = cva("flex flex-col text-card-foreground", { +const cardVariants = cva("flex flex-col gap-6 py-6 text-card-foreground", { variants: { variant: { default: GLASS_CLASSES, diff --git a/apps/apollo-vertex/templates/dashboard/AutopilotInsight.tsx b/apps/apollo-vertex/templates/dashboard/AutopilotInsight.tsx new file mode 100644 index 000000000..4f98b2323 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/AutopilotInsight.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface AutopilotInsightProps { + onClose: () => void; + sourceCardTitle: string; +} + +export function AutopilotInsight({ + onClose, + sourceCardTitle, +}: AutopilotInsightProps) { + return ( + + {/* Close button */} + + + +
+ Autopilot + Autopilot + + Autopilot Insight + +
+

+ Analyzing {sourceCardTitle} +

+
+ + + {/* Placeholder for future chat UX responses */} +
+
+

+ Autopilot response area +

+

+ Chat UX content will appear here +

+
+
+
+
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardCards.tsx b/apps/apollo-vertex/templates/dashboard/DashboardCards.tsx new file mode 100644 index 000000000..3a6ad30aa --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardCards.tsx @@ -0,0 +1,288 @@ +import { AlertTriangle, CheckCircle, Clock, XCircle } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +// --- Types --- + +export interface KpiItem { + label: string; + value: string; + icon: LucideIcon; + change: string; +} + +// --- Sample data --- + +const invoices = [ + { + id: "INV-4021", + vendor: "Acme Corp", + amount: "$12,450.00", + status: "Processed" as const, + date: "Mar 18, 2026", + }, + { + id: "INV-4020", + vendor: "Global Supplies Ltd", + amount: "$3,280.50", + status: "Pending" as const, + date: "Mar 18, 2026", + }, + { + id: "INV-4019", + vendor: "TechParts Inc", + amount: "$8,920.00", + status: "In Review" as const, + date: "Mar 17, 2026", + }, + { + id: "INV-4018", + vendor: "Office Depot", + amount: "$1,150.75", + status: "Processed" as const, + date: "Mar 17, 2026", + }, + { + id: "INV-4017", + vendor: "CloudServ Solutions", + amount: "$24,000.00", + status: "Failed" as const, + date: "Mar 16, 2026", + }, + { + id: "INV-4016", + vendor: "Metro Logistics", + amount: "$6,780.00", + status: "Processed" as const, + date: "Mar 16, 2026", + }, +]; + +const statusVariant: Record< + string, + "default" | "secondary" | "destructive" | "outline" +> = { + Processed: "default", + Pending: "secondary", + Failed: "destructive", + "In Review": "outline", +}; + +const statusIcon: Record = { + Processed: CheckCircle, + Pending: Clock, + Failed: XCircle, + "In Review": AlertTriangle, +}; + +const activityBars = [ + { label: "Mon", height: 60 }, + { label: "Tue", height: 85 }, + { label: "Wed", height: 45 }, + { label: "Thu", height: 92 }, + { label: "Fri", height: 78 }, + { label: "Sat", height: 30 }, + { label: "Sun", height: 15 }, +]; + +const recentActivity = [ + { text: "INV-4021 processed successfully", time: "2 min ago" }, + { text: "INV-4020 submitted for review", time: "15 min ago" }, + { text: "Batch processing completed (42 invoices)", time: "1 hr ago" }, + { text: "INV-4017 failed — missing PO number", time: "3 hrs ago" }, +]; + +const pipelineStages = [ + { label: "OCR Extraction", value: 96 }, + { label: "Field Validation", value: 88 }, + { label: "Approval Routing", value: 72 }, + { label: "Final Review", value: 64 }, +]; + +const complianceChecks = [ + { label: "Income Verification", pass: 98 }, + { label: "Credit Score Threshold", pass: 96 }, + { label: "Debt-to-Income Ratio", pass: 91 }, + { label: "Collateral Appraisal", pass: 87 }, + { label: "Document Completeness", pass: 94 }, +]; + +// --- Card components --- + +export function KpiCards({ kpis }: { kpis: KpiItem[] }) { + return ( + <> + {kpis.map((kpi) => ( + + +
+ + {kpi.label} + + +
+
+ +
{kpi.value}
+

+ {kpi.change} from last + week +

+
+
+ ))} + + ); +} + +export function InvoiceTable() { + return ( + + + Recent Invoices + + + + + + Invoice + Vendor + Amount + Status + Date + + + + {invoices.map((inv) => { + const StatusIcon = statusIcon[inv.status]; + return ( + + {inv.id} + {inv.vendor} + {inv.amount} + + + + {inv.status} + + + + {inv.date} + + + ); + })} + +
+
+
+ ); +} + +export function ActivityBarChart() { + return ( + + + Processing Activity + + +
+ {activityBars.map((bar) => ( +
+
+ {bar.label} +
+ ))} +
+ + + ); +} + +export function ActivityFeed() { + return ( + + + Recent Activity + + +
+ {recentActivity.map((event) => ( +
+
+
+

{event.text}

+

{event.time}

+
+
+ ))} +
+ + + ); +} + +export function PipelineProgress() { + return ( + + + Processing Pipeline + + +
+ {pipelineStages.map((stage) => ( +
+
+ {stage.label} + {stage.value}% +
+ +
+ ))} +
+
+
+ ); +} + +export function ComplianceProgress() { + return ( + + + Compliance Pass Rates + + +
+ {complianceChecks.map((check) => ( +
+
+ {check.label} + {check.pass}% +
+ +
+ ))} +
+
+
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardContent.tsx b/apps/apollo-vertex/templates/dashboard/DashboardContent.tsx new file mode 100644 index 000000000..07673ad7a --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardContent.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { DashboardGlow } from "./DashboardGlow"; +import { + cardBgStyle, + defaultDarkCards, + defaultDarkGlow, + defaultLayout, + type CardConfig, + type GlowConfig, + type LayoutConfig, +} from "./glow-config"; +import { GlowDevControls } from "./GlowDevControls"; +import { InsightGrid } from "./InsightGrid"; +import { DashboardLoading } from "./DashboardLoading"; +import { PromptBar } from "./PromptBar"; +import { AutopilotInsight } from "./AutopilotInsight"; +import { + useDashboardData, + DashboardDataProvider, +} from "./DashboardDataProvider"; + +type LayoutType = "executive" | "operational" | "analytics"; + +function ExecutiveLayout({ + cards, + layout, + viewMode, + onAutopilotOpen, + autopilotActiveIdx, +}: { + cards: CardConfig; + layout: LayoutConfig; + viewMode: ViewMode; + onAutopilotOpen?: (sourceTitle: string, idx: number) => void; + autopilotActiveIdx?: number | null; +}) { + const { data } = useDashboardData(); + const [promptExpanded, setPromptExpanded] = useState(false); + const borderClass = cards.borderVisible ? "" : "dark:!border-transparent"; + const blurClass = cards.backdropBlur ? "" : "dark:!backdrop-blur-none"; + const shared = `!shadow-none dark:![background:var(--card-bg-override)] ${borderClass} ${blurClass}`; + const gapStyle = { gap: `${layout.gap}px` }; + + return ( +
+
+
+ + + Autopilot + Autopilot + + {data.greeting} + + + +
+

+ {data.headline} +

+

+ {data.subhead} +

+
+
+
+
+ setPromptExpanded(true)} + onExpand={() => setPromptExpanded(true)} + onCollapse={() => setPromptExpanded(false)} + /> +
+
+ +
+
+ ); +} + +function OperationalLayout() { + return ( +
+ Operational layout — coming soon +
+ ); +} + +function AnalyticsLayout() { + return ( +
+ Analytics layout — coming soon +
+ ); +} + +// --- Main component --- + +type ViewMode = "desktop" | "compact" | "stacked"; + +function useViewMode(ref: React.RefObject): ViewMode { + const [mode, setMode] = useState("desktop"); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new ResizeObserver(([entry]) => { + const w = entry.contentRect.width; + if (w >= 1100) setMode("desktop"); + else if (w >= 800) setMode("compact"); + else setMode("stacked"); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [ref]); + + return mode; +} + +function DashboardContentInner() { + const { data } = useDashboardData(); + const [layout] = useState("executive"); + const [darkGlow, setDarkGlow] = useState(defaultDarkGlow); + const [darkCards, setDarkCards] = useState(defaultDarkCards); + const [layoutCfg, setLayoutCfg] = useState(defaultLayout); + const [replayCount] = useState(0); + const [autopilotOpen, setAutopilotOpen] = useState(false); + const [autopilotSource, setAutopilotSource] = useState(""); + const [autopilotActiveIdx, setAutopilotActiveIdx] = useState( + null, + ); + const containerRef = useRef(null); + const viewMode = useViewMode(containerRef); + + const handleAutopilotOpen = (sourceTitle: string, idx: number) => { + if (autopilotOpen && autopilotActiveIdx === idx) { + setAutopilotOpen(false); + setAutopilotActiveIdx(null); + } else { + setAutopilotSource(sourceTitle); + setAutopilotActiveIdx(idx); + setAutopilotOpen(true); + } + }; + + const handleAutopilotClose = () => { + setAutopilotOpen(false); + setAutopilotActiveIdx(null); + }; + + return ( + +
+ + +
+ {/* Header — stays in place */} +
+
+

+ {data.brandName}{" "} + {data.brandLine} +

+

+ {data.dashboardTitle} + + {data.badgeText} + +

+
+
+ + +
+
+ + {/* Layout content */} +
+ {/* Dashboard cards — shifts left for autopilot */} +
+ {layout === "executive" && ( + + )} + {layout === "operational" && } + {layout === "analytics" && } +
+ {/* Autopilot panel — slides in from right */} +
+
+ +
+
+
+
+
+
+ ); +} + +export function DashboardContent() { + return ( + + + + ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardDataProvider.tsx b/apps/apollo-vertex/templates/dashboard/DashboardDataProvider.tsx new file mode 100644 index 000000000..d1d56a42e --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardDataProvider.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { createContext, useContext, useState, type ReactNode } from "react"; +import { ecommerceDataset, type DashboardDataset } from "./dashboard-data"; + +interface DashboardDataContextValue { + data: DashboardDataset; + setDataset: (data: DashboardDataset) => void; +} + +const DashboardDataContext = createContext({ + data: ecommerceDataset, + setDataset: () => {}, +}); + +export function useDashboardData() { + return useContext(DashboardDataContext); +} + +export function DashboardDataProvider({ children }: { children: ReactNode }) { + const [data, setData] = useState(ecommerceDataset); + + return ( + + {children} + + ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardGlow.tsx b/apps/apollo-vertex/templates/dashboard/DashboardGlow.tsx new file mode 100644 index 000000000..953d42119 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardGlow.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { + defaultDarkGlow, + defaultLightGlow, + type GlowConfig, +} from "./glow-config"; + +interface DashboardGlowProps { + className?: string; + darkConfig?: GlowConfig; +} + +function GlowSvg({ id, config }: { id: string; config: GlowConfig }) { + return ( + + + + + + + + + + + + + + + + + ); +} + +export function DashboardGlow({ className, darkConfig }: DashboardGlowProps) { + const light = defaultLightGlow; + const dark = darkConfig ?? defaultDarkGlow; + + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardLoading.tsx b/apps/apollo-vertex/templates/dashboard/DashboardLoading.tsx new file mode 100644 index 000000000..9c378dc38 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardLoading.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +type Phase = "logo" | "skeleton" | "done"; + +interface DashboardLoadingProps { + children: React.ReactNode; + triggerReplay?: number; +} + +function LogoPhase({ exiting }: { exiting: boolean }) { + return ( +
+ {/* Morphing glow */} +
+
+
+
+
+
+ + {/* App icon */} +
+ UiPath +
+ + {/* Loading text */} +

+ Creating your overview... +

+ + +
+ ); +} + +function SkeletonPhase({ exiting }: { exiting: boolean }) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export function DashboardLoading({ + children, + triggerReplay, +}: DashboardLoadingProps) { + const [phase, setPhase] = useState("done"); + const [exiting, setExiting] = useState(false); + + const startSequence = useCallback(() => { + setExiting(false); + setPhase("logo"); + }, []); + + useEffect(() => { + if (triggerReplay === 0) return; + if (triggerReplay) startSequence(); + }, [triggerReplay, startSequence]); + + useEffect(() => { + if (phase === "done") return; + + if (phase === "logo") { + const timer = setTimeout(() => { + setExiting(true); + setTimeout(() => { + setExiting(false); + setPhase("skeleton"); + }, 500); + }, 2000); + return () => clearTimeout(timer); + } + + if (phase === "skeleton") { + const timer = setTimeout(() => { + setExiting(true); + setTimeout(() => { + setPhase("done"); + }, 500); + }, 1000); + return () => clearTimeout(timer); + } + }, [phase]); + + if (phase === "done") { + return ( +
{children}
+ ); + } + + return ( +
+ {phase === "logo" && } + {phase === "skeleton" && } +
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardRoutes.tsx b/apps/apollo-vertex/templates/dashboard/DashboardRoutes.tsx new file mode 100644 index 000000000..30965402e --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardRoutes.tsx @@ -0,0 +1,59 @@ +import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"; +import { DashboardContent } from "./DashboardContent"; +import { DashboardShellWrapper } from "./DashboardShellWrapper"; + +export const dashboardRootRoute = createRootRoute(); + +// --- Sidebar variant routes --- + +export const dashboardShellRoute = createRoute({ + getParentRoute: () => dashboardRootRoute, + path: "/preview/dashboard", + component: () => ( + + + + ), +}); + +export const dashboardIndexRoute = createRoute({ + getParentRoute: () => dashboardShellRoute, + path: "/", + component: DashboardContent, +}); + +export const dashboardHomeRoute = createRoute({ + getParentRoute: () => dashboardShellRoute, + path: "/home", + component: DashboardContent, +}); + +export const dashboardCatchAllRoute = createRoute({ + getParentRoute: () => dashboardShellRoute, + path: "$", + component: DashboardContent, +}); + +// --- Minimal variant routes --- + +export const dashboardMinimalShellRoute = createRoute({ + getParentRoute: () => dashboardRootRoute, + path: "/preview/dashboard-minimal", + component: () => ( + + + + ), +}); + +export const dashboardMinimalIndexRoute = createRoute({ + getParentRoute: () => dashboardMinimalShellRoute, + path: "/", + component: DashboardContent, +}); + +export const dashboardMinimalCatchAllRoute = createRoute({ + getParentRoute: () => dashboardMinimalShellRoute, + path: "$", + component: DashboardContent, +}); diff --git a/apps/apollo-vertex/templates/dashboard/DashboardShellWrapper.tsx b/apps/apollo-vertex/templates/dashboard/DashboardShellWrapper.tsx new file mode 100644 index 000000000..fe3ffc163 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardShellWrapper.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react"; +import type { ShellNavItem } from "@/registry/shell/shell"; +import { ApolloShell } from "@/registry/shell/shell"; +import { BarChart3, FolderOpen, Home, Settings, Users } from "lucide-react"; + +const sidebarNavItems: ShellNavItem[] = [ + { path: "/preview/dashboard/home", label: "dashboard", icon: Home }, + { path: "/preview/dashboard/projects", label: "projects", icon: FolderOpen }, + { path: "/preview/dashboard/analytics", label: "analytics", icon: BarChart3 }, + { path: "/preview/dashboard/team", label: "team", icon: Users }, + { path: "/preview/dashboard/settings", label: "settings", icon: Settings }, +]; + +const minimalNavItems: ShellNavItem[] = [ + { path: "/preview/dashboard-minimal", label: "dashboard", icon: Home }, + { + path: "/preview/dashboard-minimal/projects", + label: "projects", + icon: FolderOpen, + }, + { + path: "/preview/dashboard-minimal/analytics", + label: "analytics", + icon: BarChart3, + }, +]; + +export function DashboardShellWrapper({ + variant, + children, +}: { + variant?: "minimal"; + children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardTemplate.tsx b/apps/apollo-vertex/templates/dashboard/DashboardTemplate.tsx new file mode 100644 index 000000000..5e2d6ae00 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardTemplate.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { + dashboardCatchAllRoute, + dashboardHomeRoute, + dashboardIndexRoute, + dashboardMinimalCatchAllRoute, + dashboardMinimalIndexRoute, + dashboardMinimalShellRoute, + dashboardRootRoute, + dashboardShellRoute, +} from "./DashboardRoutes"; + +export interface DashboardTemplateProps { + shellVariant?: "minimal"; +} + +const DASHBOARD_PREVIEW_PATH_KEY = "dashboard-preview-path"; +const DASHBOARD_MINIMAL_PREVIEW_PATH_KEY = "dashboard-minimal-preview-path"; + +type DashboardPreviewPathKey = + | typeof DASHBOARD_PREVIEW_PATH_KEY + | typeof DASHBOARD_MINIMAL_PREVIEW_PATH_KEY; + +const queryClient = new QueryClient(); + +const routeTree = dashboardRootRoute.addChildren([ + dashboardShellRoute.addChildren([ + dashboardIndexRoute, + dashboardHomeRoute, + dashboardCatchAllRoute, + ]), + dashboardMinimalShellRoute.addChildren([ + dashboardMinimalIndexRoute, + dashboardMinimalCatchAllRoute, + ]), +]); + +function getInitialEntry( + storageKey: DashboardPreviewPathKey, + variant?: "minimal", +) { + const stored = localStorage.getItem(storageKey); + if (stored) return stored; + return variant === "minimal" + ? "/preview/dashboard-minimal" + : "/preview/dashboard"; +} + +function createDashboardRouter( + storageKey: DashboardPreviewPathKey, + variant?: "minimal", +) { + const history = createMemoryHistory({ + initialEntries: [getInitialEntry(storageKey, variant)], + }); + return createRouter({ routeTree, history }); +} + +export function DashboardTemplate({ shellVariant }: DashboardTemplateProps) { + const storageKey = + shellVariant === "minimal" + ? DASHBOARD_MINIMAL_PREVIEW_PATH_KEY + : DASHBOARD_PREVIEW_PATH_KEY; + const [router] = useState(() => + createDashboardRouter(storageKey, shellVariant), + ); + + useEffect(() => { + const unsubscribe = router.subscribe("onResolved", ({ toLocation }) => { + localStorage.setItem(storageKey, toLocation.pathname); + }); + return unsubscribe; + }, [router, storageKey]); + + return ( + + + + ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardTemplateDynamic.tsx b/apps/apollo-vertex/templates/dashboard/DashboardTemplateDynamic.tsx new file mode 100644 index 000000000..8e522eaa5 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardTemplateDynamic.tsx @@ -0,0 +1,16 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type React from "react"; + +type DashboardTemplateProps = React.ComponentProps< + typeof import("./DashboardTemplate").DashboardTemplate +>; + +export const DashboardTemplate = dynamic( + () => + import("./DashboardTemplate").then((mod) => ({ + default: mod.DashboardTemplate, + })), + { ssr: false }, +); diff --git a/apps/apollo-vertex/templates/dashboard/ExpandedInsightContent.tsx b/apps/apollo-vertex/templates/dashboard/ExpandedInsightContent.tsx new file mode 100644 index 000000000..e3321b5cb --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/ExpandedInsightContent.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; + +// --- Sample data --- + +const trendData = { + weeks: ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8"], + series: [ + { + label: "Wrong size/fit", + color: "bg-chart-1", + stroke: "stroke-chart-1", + values: [31, 33, 34, 35, 36, 37, 39, 41], + }, + { + label: "Damaged in transit", + color: "bg-chart-2", + stroke: "stroke-chart-2", + values: [25, 24, 26, 23, 22, 24, 23, 21], + }, + { + label: "Not as described", + color: "bg-chart-3", + stroke: "stroke-chart-3", + values: [20, 19, 18, 19, 18, 17, 18, 17], + }, + ], + takeaway: + "Fit-related returns have grown steadily over 8 weeks (+32%), while damage and description issues remain flat.", +}; + +const categoryBreakdown = [ + { category: "Women's Apparel", pct: 48, highlight: true }, + { category: "Footwear", pct: 27 }, + { category: "Men's Apparel", pct: 16 }, + { category: "Accessories", pct: 9 }, +]; +const categoryInsight = + "Women's apparel and footwear account for 75% of all fit-related returns. Sizing inconsistency across brands is the primary driver."; + +const topProducts = [ + { + name: "Slim Fit Chinos — Navy", + returnRate: 18.4, + issue: "Wrong size", + impact: "$12,400", + }, + { + name: "Running Shoe Pro V2", + returnRate: 15.2, + issue: "Wrong fit", + impact: "$9,800", + }, + { + name: "Wrap Dress — Floral", + returnRate: 14.7, + issue: "Wrong size", + impact: "$8,200", + }, + { + name: "Oversized Hoodie — Black", + returnRate: 12.1, + issue: "Too large", + impact: "$6,900", + }, + { + name: "Ankle Boot — Tan", + returnRate: 11.8, + issue: "Wrong fit", + impact: "$5,400", + }, +]; + +const recommendations = [ + { + action: "Deploy dynamic size recommendation for top 3 SKUs", + impact: "Est. 22% reduction in fit returns", + priority: "High", + }, + { + action: "Add fit-specific review prompts to product pages", + impact: "Improve size confidence pre-purchase", + priority: "Medium", + }, + { + action: "Flag brands with >15% size variance for supplier review", + impact: "Address root cause across catalog", + priority: "Medium", + }, +]; + +const suggestedPrompts = [ + "Why are fit-related returns increasing?", + "Which products are driving return volume?", + "What orders are at risk of return?", +]; + +// --- Components --- + +function TrendChart({ data }: { data: typeof trendData }) { + const allValues = data.series.flatMap((s) => s.values); + const max = Math.max(...allValues); + const h = 60; + const w = 180; + const step = w / (data.weeks.length - 1); + + return ( +
+
+
+ Trend over time +
+

+ 8-week view of the top 3 return reasons +

+
+ + {data.series.map((series) => { + const points = series.values + .map((v, i) => `${i * step},${h - (v / max) * h * 0.85}`) + .join(" "); + return ( + + ); + })} + +
+ {data.series.map((s) => ( +
+
+ {s.label} +
+ ))} +
+
+

{data.takeaway}

+
+
+ ); +} + +function CategoryBreakdown() { + return ( +
+
+
+ Category breakdown +
+

+ Where "Wrong size/fit" returns are concentrated +

+
+
+ {categoryBreakdown.map((cat) => ( +
+
+ {cat.category} + {cat.pct}% +
+
+
+
+
+
+
+ ))} +
+
+

{categoryInsight}

+
+
+ ); +} + +function TopProducts() { + return ( +
+
+
+ Top products driving issues +
+

+ Ranked by return rate with revenue impact +

+
+
+
+ Product + Return % + Issue + Impact +
+ {topProducts.map((p) => ( +
+ {p.name} + + {p.returnRate}% + + + {p.issue} + + + {p.impact} + +
+ ))} +
+
+ ); +} + +function Recommendations() { + return ( +
+
+
+ Recommended actions +
+

+ AI-assisted next steps based on current data +

+
+
+ {recommendations.map((rec, i) => ( +
+
+ + {i + 1} + + + {rec.priority} + +
+

{rec.action}

+

+ {rec.impact} +

+
+ ))} +
+
+ ); +} + +// --- Exports --- + +export type DrilldownTab = + | "overview" + | "trend" + | "categories" + | "products" + | "actions"; + +export const drilldownTabs: { key: DrilldownTab; label: string }[] = [ + { key: "overview", label: "Overview" }, + { key: "categories", label: "Categories" }, + { key: "products", label: "Products" }, + { key: "actions", label: "Actions" }, +]; + +export function DrilldownTabContent({ tab }: { tab: DrilldownTab }) { + if (tab === "trend") return ; + if (tab === "categories") return ; + if (tab === "products") return ; + if (tab === "actions") return ; + return null; // "overview" is handled by the original card content +} + +export function AutopilotPrompts({ + onPromptSelect, +}: { + onPromptSelect?: (prompt: string) => void; +}) { + const [pressedPrompt, setPressedPrompt] = useState(null); + + return ( +
+
+ Autopilot + Autopilot + Ask Autopilot +
+
+ {suggestedPrompts.map((prompt) => ( + + ))} +
+
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/GlowDevControls.tsx b/apps/apollo-vertex/templates/dashboard/GlowDevControls.tsx new file mode 100644 index 000000000..036aeb1bb --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/GlowDevControls.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useRef, useState } from "react"; +import type { CardConfig, GlowConfig, LayoutConfig } from "./glow-config"; +import { CardsTab, GlowTab, LayoutTab } from "./dev-controls-tabs"; +import { useDashboardData } from "./DashboardDataProvider"; +import { datasetPresets, type DashboardDataset } from "./dashboard-data"; + +interface DevControlsProps { + glowConfig: GlowConfig; + onGlowChange: (config: GlowConfig) => void; + cardConfig: CardConfig; + onCardChange: (config: CardConfig) => void; + layoutConfig: LayoutConfig; + onLayoutChange: (config: LayoutConfig) => void; +} + +type Tab = "glow" | "cards" | "layout" | "data"; + +export function GlowDevControls({ + glowConfig, + onGlowChange, + cardConfig, + onCardChange, + layoutConfig, + onLayoutChange, +}: DevControlsProps) { + const [open, setOpen] = useState(false); + const [tab, setTab] = useState("glow"); + const { data, setDataset } = useDashboardData(); + const fileInputRef = useRef(null); + const [uploadError, setUploadError] = useState(""); + const [uploadedDatasets, setUploadedDatasets] = useState( + [], + ); + + const configMap: Record = { + glow: glowConfig, + cards: cardConfig, + layout: layoutConfig, + data: data, + }; + const currentConfig = configMap[tab]; + + return ( + <> + { + const file = e.target.files?.[0]; + if (!file) return; + setUploadError(""); + const reader = new FileReader(); + reader.onload = () => { + try { + const parsed = JSON.parse( + reader.result as string, + ) as DashboardDataset; + if (!parsed.brandName || !parsed.insightCards) { + setUploadError("Invalid format"); + return; + } + setUploadedDatasets((prev) => { + const exists = prev.some((d) => d.name === parsed.name); + return exists + ? prev.map((d) => (d.name === parsed.name ? parsed : d)) + : [...prev, parsed]; + }); + setDataset(parsed); + } catch { + setUploadError("Invalid JSON"); + } + }; + reader.readAsText(file); + e.target.value = ""; + }} + /> +
+ {open && ( +
+
+ + + + +
+
+ {tab === "glow" && ( + + )} + {tab === "cards" && ( + + )} + {tab === "layout" && ( + + )} + {tab === "data" && ( +
+
+ Dataset: {data.name} +
+
+
Preset
+ +
+
+ + +
+ {uploadError && ( +
+ {uploadError} +
+ )} +
+ )} +
+
Config:
+
+                  {JSON.stringify(currentConfig, null, 2)}
+                
+
+
+
+ )} + +
+ + ); +} diff --git a/apps/apollo-vertex/templates/dashboard/InsightGrid.tsx b/apps/apollo-vertex/templates/dashboard/InsightGrid.tsx new file mode 100644 index 000000000..e57ca42a2 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/InsightGrid.tsx @@ -0,0 +1,508 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ArrowUpRight, Maximize2, Minimize2 } from "lucide-react"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + cardBgStyle, + getInsightCardClasses, + type CardConfig, + type InsightCardConfig, + type LayoutConfig, +} from "./glow-config"; +import { InsightCardBody } from "./insight-card-renderers"; +import { useDashboardData } from "./DashboardDataProvider"; +import { + type DrilldownTab, + drilldownTabs, + DrilldownTabContent, + AutopilotPrompts, +} from "./ExpandedInsightContent"; + +const sizeToFr: Record = { sm: "1fr", md: "2fr", lg: "1fr" }; + +// --- Shared card inner content --- + +interface InsightCardInnerProps { + cfg: InsightCardConfig; + cardIndex: number; + shared: string; + cards: CardConfig; + isExpanding: boolean; + isThis: boolean; + phase: ExpandPhase; + viewMode: "desktop" | "compact" | "stacked"; + drilldownTab: DrilldownTab; + onDrilldownTabChange: (tab: DrilldownTab) => void; + onExpandClick: () => void; + onAutopilotOpen?: () => void; + isAutopilotActive?: boolean; + className?: string; + style?: React.CSSProperties; +} + +function InsightCardInner({ + cfg, + cardIndex, + shared, + cards, + isExpanding, + isThis, + phase, + viewMode, + onExpandClick, + onAutopilotOpen, + drilldownTab, + onDrilldownTabChange, + isAutopilotActive = false, + className = "", + style, +}: InsightCardInnerProps) { + const { data } = useDashboardData(); + const cardTitle = data.insightCards[cardIndex]?.title ?? cfg.content.title; + const hasDrilldown = cfg.content.chartType === "horizontal-bars"; + const isExpandedWithDrilldown = + isThis && isExpanding && hasDrilldown && phase === "full"; + const classes = getInsightCardClasses(cfg.content, viewMode); + const isInteractive = cfg.interaction !== "static"; + + return ( + + + + {cardTitle} + + {isInteractive && cfg.interaction === "expand" && ( + +
+ {onAutopilotOpen && ( + + )} + +
+
+ )} + {isInteractive && cfg.interaction === "navigate" && !isThis && ( + + + + )} + {/* Drilldown tabs — below title when expanded */} + {isThis && + isExpanding && + hasDrilldown && + (phase === "height" || phase === "full") && + (() => { + const visibleTabs = drilldownTabs.slice(0, 4); + const overflowTabs = drilldownTabs.slice(4); + const isOverflowActive = overflowTabs.some( + (t) => t.key === drilldownTab, + ); + return ( +
+ {visibleTabs.map((tab) => ( + + ))} + {overflowTabs.length > 0 && ( +
+ + + + +
+ )} +
+ ); + })()} +
+ {isExpandedWithDrilldown ? ( + /* Expanded with drilldown — unified layout for all tabs */ +
+
+
+ {drilldownTab === "overview" ? ( + + ) : ( + + )} +
+
+
+ onAutopilotOpen?.()} /> +
+
+ ) : ( + /* Default card content — not expanded or no drilldown */ + + + + )} + {/* Non-drilldown expanded content (other card types) */} + {isThis && + isExpanding && + !hasDrilldown && + (phase === "height" || phase === "full") && ( +
+ {phase === "full" ? ( +
+ + Additional content + +
+ ) : ( +
+
+
+
+
+ )} +
+ )} + + ); +} + +// --- Main grid --- + +type ExpandPhase = "idle" | "width" | "height" | "full"; + +export function InsightGrid({ + layout, + shared, + cards, + viewMode = "desktop", + onAutopilotOpen, + autopilotActiveIdx, +}: { + layout: LayoutConfig; + shared: string; + cards: CardConfig; + viewMode?: "desktop" | "compact" | "stacked"; + onAutopilotOpen?: (sourceTitle: string, idx: number) => void; + autopilotActiveIdx?: number | null; +}) { + const { data } = useDashboardData(); + const [expandedIdx, setExpandedIdx] = useState(null); + const [phase, setPhase] = useState("idle"); + const [drilldownTab, setDrilldownTab] = useState("overview"); + + useEffect(() => { + if (expandedIdx === null) { + setPhase("idle"); + setDrilldownTab("overview"); + return; + } + requestAnimationFrame(() => setPhase("width")); + const t1 = setTimeout(() => setPhase("height"), 300); + const t2 = setTimeout(() => setPhase("full"), 600); + return () => { + clearTimeout(t1); + clearTimeout(t2); + }; + }, [expandedIdx]); + + const visibleCards = layout.insightCards + .map((cfg, i) => { + const dataCard = data.insightCards[i]; + const merged = dataCard + ? { + ...cfg, + size: dataCard.size ?? cfg.size, + interaction: dataCard.interaction ?? cfg.interaction, + content: { + ...cfg.content, + title: dataCard.title ?? cfg.content.title, + }, + } + : cfg; + return { cfg: merged, idx: i }; + }) + .filter(({ cfg }) => cfg.visible); + const rows: (typeof visibleCards)[] = []; + for (let i = 0; i < visibleCards.length; i += 2) { + rows.push(visibleCards.slice(i, i + 2)); + } + + const isExpanding = expandedIdx !== null; + const expandedRow = isExpanding + ? rows.findIndex((row) => row.some(({ idx }) => idx === expandedIdx)) + : -1; + + const handleClick = (cfg: InsightCardConfig, idx: number) => { + if (cfg.interaction === "expand") { + setExpandedIdx(expandedIdx === idx ? null : idx); + } + }; + + // Build grid-template-rows + let rowTemplates: string[]; + if (viewMode === "compact") { + rowTemplates = visibleCards.map(({ idx }) => { + if (!isExpanding) return "1fr"; + if (idx === expandedIdx) return "1fr"; + if (phase !== "idle") return "0fr"; + return "1fr"; + }); + } else { + rowTemplates = rows.map((_, rowIndex) => { + const isOtherRow = isExpanding && rowIndex !== expandedRow; + if (isOtherRow && (phase === "height" || phase === "full")) return "0fr"; + return "1fr"; + }); + } + + const sharedProps = { + shared, + cards, + isExpanding, + phase, + viewMode, + drilldownTab, + onDrilldownTabChange: setDrilldownTab, + }; + + return ( +
+ {viewMode === "compact" + ? visibleCards.map(({ cfg, idx }) => { + const isThis = idx === expandedIdx; + const isOther = isExpanding && !isThis; + return ( +
+ handleClick(cfg, idx)} + onAutopilotOpen={ + onAutopilotOpen + ? () => + onAutopilotOpen( + data.insightCards[idx]?.title ?? cfg.content.title, + idx, + ) + : undefined + } + isAutopilotActive={autopilotActiveIdx === idx} + className="h-full" + /> +
+ ); + }) + : rows.map((row, rowIndex) => { + const isRowWithExpanded = rowIndex === expandedRow; + const isOtherRow = isExpanding && !isRowWithExpanded; + const cols = row + .map(({ cfg, idx }) => { + if (!isExpanding) + return cfg.size === "lg" ? "1fr" : sizeToFr[cfg.size]; + if (idx === expandedIdx) + return phase === "idle" + ? cfg.size === "lg" + ? "1fr" + : sizeToFr[cfg.size] + : "1fr"; + if (isRowWithExpanded) + return phase === "idle" + ? cfg.size === "lg" + ? "1fr" + : sizeToFr[cfg.size] + : "0fr"; + return cfg.size === "lg" ? "1fr" : sizeToFr[cfg.size]; + }) + .join(" "); + return ( +
idx).join("-")} + className="grid transition-all duration-300 ease-in-out overflow-hidden min-h-0" + style={ + { + gridTemplateColumns: cols, + gap: isRowWithExpanded && phase !== "idle" ? 0 : layout.gap, + opacity: + isOtherRow && (phase === "height" || phase === "full") + ? 0 + : 1, + } as React.CSSProperties + } + > + {row.map(({ cfg, idx }) => { + const isThis = idx === expandedIdx; + const isSibling = isExpanding && !isThis && isRowWithExpanded; + return ( + handleClick(cfg, idx)} + onAutopilotOpen={ + onAutopilotOpen + ? () => + onAutopilotOpen( + data.insightCards[idx]?.title ?? + cfg.content.title, + idx, + ) + : undefined + } + isAutopilotActive={autopilotActiveIdx === idx} + style={{ + opacity: isSibling && phase !== "idle" ? 0 : 1, + transform: + isSibling && phase !== "idle" + ? "scale(0.95)" + : "scale(1)", + }} + /> + ); + })} +
+ ); + })} +
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/PromptBar.tsx b/apps/apollo-vertex/templates/dashboard/PromptBar.tsx new file mode 100644 index 000000000..9bd863f88 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/PromptBar.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useState } from "react"; +import { MessagesSquare, Minimize2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import type { CardConfig, CardGradient } from "./glow-config"; +import { useDashboardData } from "./DashboardDataProvider"; + +function cardBgStyle( + bg: string, + opacity: number, + gradient: CardGradient, +): React.CSSProperties { + if (gradient.enabled) { + const alpha = gradient.opacity / 100; + return { + "--card-bg-override": `linear-gradient(${gradient.angle}deg, color-mix(in srgb, ${gradient.start} ${alpha * 100}%, transparent), color-mix(in srgb, ${gradient.end} ${alpha * 100}%, transparent))`, + borderColor: "transparent", + } as React.CSSProperties; + } + const value = + bg === "white" + ? `rgba(255,255,255,${opacity / 100})` + : `color-mix(in srgb, var(--${bg}) ${opacity}%, transparent)`; + return { "--card-bg-override": value } as React.CSSProperties; +} + +export function PromptBar({ + shared, + cards, + isExpanded = false, + onSubmit, + onExpand, + onCollapse, +}: { + shared: string; + cards: CardConfig; + isExpanded?: boolean; + onSubmit?: (query: string) => void; + onExpand?: () => void; + onCollapse?: () => void; +}) { + const { data } = useDashboardData(); + const [value, setValue] = useState(""); + const hasInput = value.trim().length > 0; + + const handleSubmit = () => { + if (hasInput && onSubmit) { + onSubmit(value); + } + }; + + const handleChipClick = (suggestion: string) => { + setValue(suggestion); + onSubmit?.(suggestion); + }; + + return ( +
+ {/* Expanded response area */} + {isExpanded && ( +
+
+
+ Autopilot + Autopilot + + Autopilot + +
+ {onCollapse && ( + + )} +
+
+

+ Responses will appear here +

+
+
+
+ )} + {/* Suggestion badges — hidden when expanded */} + {!isExpanded && ( +
+
+
+ + handleChipClick( + data.promptSuggestions[0] ?? "Show me top risk factors", + ) + } + > + {data.promptSuggestions[0] ?? "Show me top risk factors"} + + + handleChipClick( + data.promptSuggestions[1] ?? + "Compare Q1 vs Q2 performance", + ) + } + > + {data.promptSuggestions[1] ?? "Compare Q1 vs Q2 performance"} + +
+
+
+ )} + {/* Input bar */} +
+ setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(); + }} + placeholder={data.promptPlaceholder} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+ + +
+
+
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/dashboard-data.ts b/apps/apollo-vertex/templates/dashboard/dashboard-data.ts new file mode 100644 index 000000000..a16846b58 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/dashboard-data.ts @@ -0,0 +1,228 @@ +export interface InsightCardData { + title: string; + type: "kpi" | "chart"; + chartType: "donut" | "horizontal-bars" | "sparkline" | "area" | "stacked-bar"; + size?: "sm" | "md" | "lg"; + interaction?: "static" | "expand" | "navigate"; + // Navigate config + navigateTo?: string; + // Expand config — additional content shown when card is expanded + expandContent?: { + summary?: string; + details?: string[]; + }; + // KPI data + kpiNumber?: string; + kpiBadge?: string; + kpiDescription?: string; + // Horizontal bars data + bars?: { label: string; value: number }[]; + // Stacked bar data + stackedBars?: { label: string; segments: number[] }[]; + stackedLegend?: string[]; + // Donut data + donutPercent?: number; + donutLabel?: string; + donutDescription?: string; + // Sparkline / Area data + points?: number[]; +} + +export interface DashboardDataset { + name: string; + brandName: string; + brandLine: string; + dashboardTitle: string; + badgeText: string; + greeting: string; + headline: string; + subhead: string; + chartLabels: { y: string[]; target: string }; + promptPlaceholder: string; + promptSuggestions: string[]; + insightCards: [ + InsightCardData, + InsightCardData, + InsightCardData, + InsightCardData, + ]; +} + +export const defaultDataset: DashboardDataset = { + name: "Loan Setup", + brandName: "UiPath", + brandLine: "Vertical Solutions", + dashboardTitle: "Product", + badgeText: "Experimental", + greeting: "Good morning, Peter", + headline: "Loan volume scales as setup time drops by 3.5 days.", + subhead: + "Setup time declined ↓21% month over month while volume increased ↑18%.", + chartLabels: { y: ["200", "150", "100", "50"], target: "Target" }, + promptPlaceholder: + "What would you like to understand about loan performance?", + promptSuggestions: [ + "Show me top risk factors", + "Compare Q1 vs Q2 performance", + ], + insightCards: [ + { + title: "Upfront decision efficiency", + type: "kpi", + chartType: "donut", + size: "sm", + interaction: "static", + kpiNumber: "94.2%", + kpiBadge: "+6.8%", + kpiDescription: "Loans finalized on first review without rework.", + }, + { + title: "Top issues", + type: "chart", + chartType: "horizontal-bars", + size: "md", + interaction: "expand", + expandContent: { + summary: "Risk flags have increased 12% this quarter", + details: ["Review underwriting criteria", "Update risk scoring model"], + }, + bars: [ + { label: "Risk flag in notes", value: 34 }, + { label: "Credit report >120 days old", value: 29 }, + { label: "Owner name mismatch", value: 23 }, + { label: "High DTI ratio", value: 14 }, + { label: "Missing appraisal docs", value: 11 }, + ], + }, + { + title: "Pipeline", + type: "chart", + chartType: "stacked-bar", + size: "md", + interaction: "expand", + expandContent: { + summary: "Weekly volume trending up with stable rejection rates", + details: [ + "Monitor Thursday spike pattern", + "Review rejected applications", + ], + }, + stackedBars: [ + { label: "Mon", segments: [30, 20, 10] }, + { label: "Tue", segments: [40, 15, 20] }, + { label: "Wed", segments: [25, 30, 15] }, + { label: "Thu", segments: [45, 10, 25] }, + { label: "Fri", segments: [35, 25, 18] }, + ], + stackedLegend: ["Approved", "Pending", "Rejected"], + }, + { + title: "SLA compliance", + type: "kpi", + chartType: "donut", + size: "sm", + interaction: "static", + kpiNumber: "99.5%", + kpiBadge: "+1.2%", + kpiDescription: "Loans processed within defined SLA thresholds.", + }, + ], +}; + +export const ecommerceDataset: DashboardDataset = { + name: "E-commerce Order Fulfillment", + brandName: "UiPath", + brandLine: "Vertical Solutions", + dashboardTitle: "Order fulfillment", + badgeText: "Experimental", + greeting: "Good morning, Peter", + headline: + "Order volume climbs as delivery performance improves, but fit-related returns remain the biggest drag on margin.", + subhead: + "Orders shipped increased ↑26% month over month while on-time delivery improved ↑2.4%, with size and fit issues now driving the largest share of returns.", + chartLabels: { y: ["600", "450", "300", "150"], target: "Target" }, + promptPlaceholder: + "What would you like to understand about order fulfillment?", + promptSuggestions: [ + "Why are fit-related returns increasing?", + "Show me products driving return volume", + "Compare warehouse performance", + "Which orders are most at risk of delay?", + ], + insightCards: [ + { + title: "On-time delivery rate", + type: "kpi", + chartType: "donut", + size: "sm", + interaction: "navigate", + kpiNumber: "97.1%", + kpiBadge: "+2.4%", + kpiDescription: + "Orders delivered within promised windows, supported by lower carrier delays and faster pick-pack turnaround.", + }, + { + title: "Top issues", + type: "chart", + chartType: "horizontal-bars", + size: "md", + interaction: "expand", + expandContent: { + summary: + "Return-related friction is now concentrated in product fit, transit handling, and expectation gaps, with apparel and footwear accounting for the highest exception volume.", + details: [ + "Investigate top SKUs contributing to wrong size and fit returns", + "Review packaging and carrier handoff for damage-related issues by warehouse", + "Use AI prompts to explain issue concentration by category, region, and fulfillment center", + ], + }, + bars: [ + { label: "Wrong size/fit", value: 39 }, + { label: "Damaged in transit", value: 23 }, + { label: "Not as described", value: 18 }, + { label: "Late delivery", value: 13 }, + { label: "Changed mind", value: 7 }, + ], + }, + { + title: "Pipeline", + type: "chart", + chartType: "stacked-bar", + size: "md", + interaction: "expand", + expandContent: { + summary: + "Fulfillment volume builds steadily through the week, with the highest shipped volume on Thursday and Friday and a midweek rise in processing backlog.", + details: [ + "Monitor Wednesday processing buildup for labor or inventory bottlenecks", + "Review Thursday and Friday shipment spikes by warehouse and carrier", + "Use AI prompts to identify whether growth is concentrated in apparel, footwear, or home goods", + ], + }, + stackedBars: [ + { label: "Mon", segments: [188, 46, 11] }, + { label: "Tue", segments: [204, 41, 13] }, + { label: "Wed", segments: [198, 57, 14] }, + { label: "Thu", segments: [236, 38, 15] }, + { label: "Fri", segments: [249, 43, 16] }, + ], + stackedLegend: ["Shipped", "Processing", "Returned"], + }, + { + title: "Customer satisfaction", + type: "kpi", + chartType: "donut", + size: "sm", + interaction: "navigate", + kpiNumber: "4.6", + kpiBadge: "+0.2", + kpiDescription: + "Average rating remains strong, though recent feedback highlights sizing inconsistency and occasional packaging damage.", + }, + ], +}; + +export const datasetPresets: Record = { + default: defaultDataset, + ecommerce: ecommerceDataset, +}; diff --git a/apps/apollo-vertex/templates/dashboard/dev-controls-primitives.tsx b/apps/apollo-vertex/templates/dashboard/dev-controls-primitives.tsx new file mode 100644 index 000000000..a779dd432 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/dev-controls-primitives.tsx @@ -0,0 +1,113 @@ +"use client"; + +export function Slider({ + label, + value, + min, + max, + step, + onChange, + displayValue, +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + onChange: (v: number) => void; + displayValue?: string; +}) { + return ( +
+
+ {label} + {displayValue ?? value} +
+ onChange(Number(e.target.value))} + className="w-full h-1 accent-primary" + /> +
+ ); +} + +export function SelectControl({ + label, + value, + options, + onChange, +}: { + label: string; + value: string; + options: { label: string; value: string }[]; + onChange: (v: string) => void; +}) { + return ( +
+
{label}
+ +
+ ); +} + +export function Toggle({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} + +export function TextInput({ + label, + value, + onChange, + placeholder, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; +}) { + return ( +
+
{label}
+ onChange(e.target.value)} + className="w-full h-7 rounded border bg-background px-1 text-xs" + placeholder={placeholder} + /> +
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/dev-controls-tabs.tsx b/apps/apollo-vertex/templates/dashboard/dev-controls-tabs.tsx new file mode 100644 index 000000000..5a6efbe03 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/dev-controls-tabs.tsx @@ -0,0 +1,398 @@ +"use client"; + +import { + bgColorOptions, + cardTypeOptions, + chartTypeOptions, + containerBgOptions, + insightOptions, + interactionOptions, + primaryOptions, + sizeOptions, + type CardConfig, + type CardGradient, + type CardInteraction, + type CardSize, + type ChartType, + type GlowConfig, + type InsightCardConfig, + type InsightCardType, + type LayoutConfig, +} from "./glow-config"; +import { + SelectControl, + Slider, + TextInput, + Toggle, +} from "./dev-controls-primitives"; + +function GradientSection({ + gradient, + onChange, +}: { + gradient: CardGradient; + onChange: (g: CardGradient) => void; +}) { + const update = (partial: Partial) => + onChange({ ...gradient, ...partial }); + + return ( +
+ update({ enabled: v })} + /> + {gradient.enabled && ( + <> + update({ start: v })} + /> + update({ end: v })} + /> + update({ angle: v })} + displayValue={`${gradient.angle}°`} + /> + update({ opacity: v })} + displayValue={`${gradient.opacity}%`} + /> + + )} +
+ ); +} + +export function GlowTab({ + config, + onChange, +}: { + config: GlowConfig; + onChange: (c: GlowConfig) => void; +}) { + const update = (partial: Partial) => + onChange({ ...config, ...partial }); + + return ( +
+ update({ start: v })} + /> + update({ end: v })} + /> + update({ containerOpacity: v })} + displayValue={`${config.containerOpacity}%`} + /> + update({ fillOpacity: v })} + /> + update({ startStopOpacity: v })} + /> + update({ endStopOpacity: v })} + /> + update({ endOffset: v })} + /> +
+ ); +} + +export function CardsTab({ + config, + onChange, +}: { + config: CardConfig; + onChange: (c: CardConfig) => void; +}) { + const update = (partial: Partial) => + onChange({ ...config, ...partial }); + + return ( +
+
Overview Card
+ update({ overviewBg: v })} + /> + update({ overviewOpacity: v })} + displayValue={`${config.overviewOpacity}%`} + /> + update({ overviewGradient: g })} + /> + +
+ Insight Cards +
+ update({ insightBg: v })} + /> + update({ insightOpacity: v })} + displayValue={`${config.insightOpacity}%`} + /> + update({ insightGradient: g })} + /> + +
Prompt Bar
+ update({ promptBg: v })} + /> + update({ promptOpacity: v })} + displayValue={`${config.promptOpacity}%`} + /> + update({ promptGradient: g })} + /> + +
Shared
+ update({ borderVisible: v })} + /> + update({ backdropBlur: v })} + /> +
+ ); +} + +export function LayoutTab({ + config, + onChange, +}: { + config: LayoutConfig; + onChange: (c: LayoutConfig) => void; +}) { + const update = (partial: Partial) => + onChange({ ...config, ...partial }); + + const updateInsightCard = ( + index: number, + partial: Partial, + ) => { + const cards = [...config.insightCards] as [ + InsightCardConfig, + InsightCardConfig, + InsightCardConfig, + InsightCardConfig, + ]; + cards[index] = { ...cards[index], ...partial }; + update({ insightCards: cards }); + }; + + return ( +
+ update({ containerBg: v })} + /> + update({ gap: v })} + displayValue={`${config.gap}px`} + /> + update({ padding: v })} + displayValue={`${config.padding}px`} + /> +
Left Column
+ update({ overviewRatio: v })} + /> + update({ promptRatio: v })} + /> +
+ Insight Cards +
+ {["Top Left", "Top Right", "Bottom Left", "Bottom Right"].map( + (label, i) => ( +
+
+ {label} + updateInsightCard(i, { visible: v })} + /> +
+ {config.insightCards[i].visible && ( + <> + + updateInsightCard(i, { size: v as CardSize }) + } + /> + + updateInsightCard(i, { + content: { + ...config.insightCards[i].content, + type: v as InsightCardType, + }, + }) + } + /> + {config.insightCards[i].content.type === "chart" && ( + + updateInsightCard(i, { + content: { + ...config.insightCards[i].content, + chartType: v as ChartType, + }, + }) + } + /> + )} + + updateInsightCard(i, { + content: { ...config.insightCards[i].content, title: v }, + }) + } + /> + + updateInsightCard(i, { + interaction: v as CardInteraction, + }) + } + /> + {config.insightCards[i].interaction === "navigate" && ( + updateInsightCard(i, { navigateTo: v })} + placeholder="/preview/dashboard/..." + /> + )} + + )} +
+ ), + )} +
+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/glow-config.ts b/apps/apollo-vertex/templates/dashboard/glow-config.ts new file mode 100644 index 000000000..c48b6fb8f --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/glow-config.ts @@ -0,0 +1,267 @@ +export interface GlowConfig { + start: string; + end: string; + containerOpacity: number; + fillOpacity: number; + startStopOpacity: number; + endStopOpacity: number; + endOffset: number; +} + +export interface CardGradient { + enabled: boolean; + start: string; + end: string; + angle: number; + opacity: number; +} + +export interface CardConfig { + overviewBg: string; + overviewOpacity: number; + overviewGradient: CardGradient; + insightBg: string; + insightOpacity: number; + insightGradient: CardGradient; + promptBg: string; + promptOpacity: number; + promptGradient: CardGradient; + borderVisible: boolean; + backdropBlur: boolean; +} + +export const defaultLightGlow: GlowConfig = { + start: "var(--insight-500)", + end: "var(--primary-400)", + containerOpacity: 70, + fillOpacity: 0.3, + startStopOpacity: 1, + endStopOpacity: 1, + endOffset: 0.35, +}; + +export const defaultDarkGlow: GlowConfig = { + start: "var(--insight-700)", + end: "var(--primary-600)", + containerOpacity: 85, + fillOpacity: 1, + startStopOpacity: 1, + endStopOpacity: 0.4, + endOffset: 0.5, +}; + +export type CardSize = "sm" | "md" | "lg"; + +export type InsightCardType = "kpi" | "chart"; +export type ChartType = + | "donut" + | "horizontal-bars" + | "sparkline" + | "area" + | "stacked-bar"; + +export interface InsightCardContent { + type: InsightCardType; + chartType: ChartType; + title: string; +} + +export type CardInteraction = "static" | "expand" | "navigate"; + +export interface InsightCardConfig { + size: CardSize; + visible: boolean; + content: InsightCardContent; + interaction: CardInteraction; + navigateTo?: string; +} + +export interface LayoutConfig { + gap: number; + overviewRatio: number; + promptRatio: number; + insightCards: [ + InsightCardConfig, + InsightCardConfig, + InsightCardConfig, + InsightCardConfig, + ]; + padding: number; + containerBg: string; +} + +export const defaultLayout: LayoutConfig = { + gap: 4, + overviewRatio: 4, + promptRatio: 1, + insightCards: [ + { + size: "sm", + visible: true, + interaction: "static", + content: { + type: "kpi", + chartType: "donut", + title: "Upfront decision efficiency", + }, + }, + { + size: "md", + visible: true, + interaction: "expand", + content: { + type: "chart", + chartType: "horizontal-bars", + title: "Top issues", + }, + }, + { + size: "md", + visible: true, + interaction: "expand", + content: { + type: "chart", + chartType: "stacked-bar", + title: "Pipeline", + }, + }, + { + size: "sm", + visible: true, + interaction: "static", + content: { + type: "kpi", + chartType: "donut", + title: "SLA compliance", + }, + }, + ], + padding: 24, + containerBg: "none", +}; + +const defaultGradient: CardGradient = { + enabled: false, + start: "var(--insight-500)", + end: "var(--primary-400)", + angle: 135, + opacity: 100, +}; + +export const insightOptions = [ + { label: "300", value: "var(--insight-300)" }, + { label: "400", value: "var(--insight-400)" }, + { label: "500", value: "var(--insight-500)" }, + { label: "600", value: "var(--insight-600)" }, + { label: "700", value: "var(--insight-700)" }, + { label: "800", value: "var(--insight-800)" }, + { label: "900", value: "var(--insight-900)" }, +]; + +export const primaryOptions = [ + { label: "300", value: "var(--primary-300)" }, + { label: "400", value: "var(--primary-400)" }, + { label: "500", value: "var(--primary-500)" }, + { label: "600", value: "var(--primary-600)" }, + { label: "700", value: "var(--primary-700)" }, + { label: "800", value: "var(--primary-800)" }, + { label: "900", value: "var(--primary-900)" }, +]; + +export const cardTypeOptions = [ + { label: "KPI", value: "kpi" }, + { label: "Chart", value: "chart" }, +]; + +export const interactionOptions = [ + { label: "Static", value: "static" }, + { label: "Expand", value: "expand" }, + { label: "Navigate", value: "navigate" }, +]; + +export const chartTypeOptions = [ + { label: "Donut", value: "donut" }, + { label: "Horizontal Bars", value: "horizontal-bars" }, + { label: "Sparkline", value: "sparkline" }, + { label: "Area", value: "area" }, + { label: "Stacked Bar", value: "stacked-bar" }, +]; + +export const sizeOptions = [ + { label: "Small (1 col)", value: "sm" }, + { label: "Medium (1 col)", value: "md" }, + { label: "Large (full)", value: "lg" }, +]; + +export const containerBgOptions = [ + { label: "None", value: "none" }, + { label: "white", value: "white" }, + { label: "sidebar", value: "sidebar" }, + { label: "card", value: "card" }, + { label: "background", value: "background" }, + { label: "muted", value: "muted" }, +]; + +export const bgColorOptions = [ + { label: "white", value: "white" }, + { label: "sidebar", value: "sidebar" }, + { label: "card", value: "card" }, + { label: "background", value: "background" }, + { label: "muted", value: "muted" }, +]; + +export function cardBgStyle( + bg: string, + opacity: number, + gradient: CardGradient, +): React.CSSProperties { + if (gradient.enabled) { + const alpha = gradient.opacity / 100; + return { + "--card-bg-override": `linear-gradient(${gradient.angle}deg, color-mix(in srgb, ${gradient.start} ${alpha * 100}%, transparent), color-mix(in srgb, ${gradient.end} ${alpha * 100}%, transparent))`, + borderColor: "transparent", + } as React.CSSProperties; + } + const value = + bg === "white" + ? `rgba(255,255,255,${opacity / 100})` + : `color-mix(in srgb, var(--${bg}) ${opacity}%, transparent)`; + return { "--card-bg-override": value } as React.CSSProperties; +} + +export function getInsightCardClasses( + content: InsightCardContent, + viewMode: "desktop" | "compact" | "stacked" = "desktop", +): { + cardClassName: string; + contentClassName: string; +} { + if (content.type === "kpi") { + const isCompact = viewMode === "compact"; + return { + cardClassName: isCompact ? "!gap-0" : "!gap-4", + contentClassName: isCompact + ? "flex-1 flex flex-col overflow-hidden" + : "flex-1 flex flex-col", + }; + } + const isBarChart = content.chartType === "horizontal-bars"; + return { + cardClassName: content.chartType === "donut" ? "!gap-0" : "", + contentClassName: isBarChart ? "flex-1" : "flex-1 flex flex-col", + }; +} + +export const defaultDarkCards: CardConfig = { + overviewBg: "sidebar", + overviewOpacity: 69, + overviewGradient: { ...defaultGradient, opacity: 30 }, + insightBg: "sidebar", + insightOpacity: 60, + insightGradient: { ...defaultGradient }, + promptBg: "sidebar", + promptOpacity: 80, + promptGradient: { ...defaultGradient }, + borderVisible: false, + backdropBlur: true, +}; diff --git a/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx b/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx new file mode 100644 index 000000000..ff08e13fd --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx @@ -0,0 +1,445 @@ +"use client"; + +import { useRef, useState, useEffect } from "react"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/registry/tooltip/tooltip"; +import type { InsightCardContent } from "./glow-config"; +import { useDashboardData } from "./DashboardDataProvider"; +import type { InsightCardData } from "./dashboard-data"; + +type ViewMode = "desktop" | "compact" | "stacked"; + +// --- Truncated text with conditional tooltip --- + +function TruncatedText({ + children, + className, +}: { + children: string | undefined; + className?: string; +}) { + const textRef = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + useEffect(() => { + const el = textRef.current; + if (!el) return; + const check = () => setIsTruncated(el.scrollHeight > el.clientHeight); + check(); + const observer = new ResizeObserver(check); + observer.observe(el); + return () => observer.disconnect(); + }, [children]); + + const textEl = ( +

+ {children} +

+ ); + + if (!isTruncated) return textEl; + + return ( + + {textEl} + + {children} + + + ); +} + +// --- Sample data per card type --- + +const sparklinePoints = [4, 7, 5, 9, 6, 8, 12, 10, 14, 11, 15, 13]; +const areaPoints = [3, 5, 4, 8, 6, 9, 7, 11, 10, 14, 12, 16]; + +// --- Renderers --- + +function KpiContent({ + cardData, + viewMode, +}: { + cardData: InsightCardData; + viewMode: ViewMode; +}) { + if (viewMode === "compact") { + return ( + <> +
+
+ {cardData.kpiNumber} +
+ + {cardData.kpiBadge} + +
+ + {cardData.kpiDescription} + + + ); + } + + return ( + <> +
+ {cardData.kpiNumber} +
+
+ + {cardData.kpiBadge} + +

+ {cardData.kpiDescription} +

+
+ + ); +} + +function DonutContent() { + return ( +
+
+ + + + +
+ + 47% + + + funded + +
+
+
+ ); +} + +function HorizontalBarsContent({ + cardData, + viewMode, + isExpanded = false, +}: { + cardData: InsightCardData; + viewMode: ViewMode; + isExpanded?: boolean; +}) { + const bars = cardData.bars ?? []; + const chartColors = [ + "bg-chart-1", + "bg-chart-2", + "bg-chart-3", + "bg-chart-4", + "bg-chart-5", + ]; + const barsWithColor = bars.map((b, i) => ({ + ...b, + color: chartColors[i % chartColors.length], + })); + + if (viewMode === "compact" && !isExpanded) { + const total = barsWithColor.reduce((sum, s) => sum + s.value, 0); + return ( +
+
+ {barsWithColor.map((issue) => ( +
+
+
+ ))} +
+
+ {barsWithColor.map((issue) => { + const pct = Math.round((issue.value / total) * 100); + return ( +
+
+ + {issue.label} {pct}% + +
+ ); + })} +
+
+ ); + } + + return ( +
+ {barsWithColor.map((issue) => ( +
+
+ {issue.label} + {issue.value}% +
+
+
+
+
+
+
+ ))} +
+ ); +} + +function SparklineContent() { + const max = Math.max(...sparklinePoints); + const h = 40; + const w = 120; + const step = w / (sparklinePoints.length - 1); + const points = sparklinePoints + .map((v, i) => `${i * step},${h - (v / max) * h}`) + .join(" "); + + return ( +
+ + + +
+ ); +} + +function AreaContent() { + const max = Math.max(...areaPoints); + const h = 40; + const w = 120; + const step = w / (areaPoints.length - 1); + const linePoints = areaPoints + .map((v, i) => `${i * step},${h - (v / max) * h}`) + .join(" "); + const areaPath = `0,${h} ${linePoints} ${w},${h}`; + + return ( +
+ + + + +
+ ); +} + +function StackedBarContent({ + cardData, + viewMode, + isExpanded = false, +}: { + cardData: InsightCardData; + viewMode: ViewMode; + isExpanded?: boolean; +}) { + const chartColors = [ + "bg-chart-1", + "bg-chart-2", + "bg-chart-3", + "bg-chart-4", + "bg-chart-5", + ]; + const rawBars = cardData.stackedBars ?? []; + const legend = (cardData.stackedLegend ?? []).map((label, i) => ({ + label, + color: chartColors[i % chartColors.length], + })); + const barData = rawBars.map((bar) => ({ + label: bar.label, + segments: bar.segments.map((value, i) => ({ + value, + color: chartColors[i % chartColors.length], + })), + })); + const maxTotal = Math.max( + ...barData.map((d) => d.segments.reduce((sum, s) => sum + s.value, 0)), + ); + + if (viewMode === "compact" && !isExpanded) { + // Summary: aggregate all days into one horizontal stacked bar + const totals = barData.reduce( + (acc, day) => { + for (const seg of day.segments) { + const key = seg.color; + acc[key] = (acc[key] ?? 0) + seg.value; + } + return acc; + }, + {} as Record, + ); + const grandTotal = Object.values(totals).reduce((a, b) => a + b, 0); + + return ( +
+
+ {legend.map((item) => ( +
+
+
+ ))} +
+
+ {legend.map((item) => { + const val = totals[item.color] ?? 0; + const pct = Math.round((val / grandTotal) * 100); + return ( +
+
+ + {item.label} {pct}% + +
+ ); + })} +
+
+ ); + } + + return ( +
+
+ {barData.map((bar) => { + const total = bar.segments.reduce((sum, s) => sum + s.value, 0); + const pct = (total / maxTotal) * 100; + return ( +
+
+
+ {bar.segments.map((seg) => ( +
+ ))} +
+
+ {bar.segments.map((seg) => ( +
+ ))} +
+
+ + {bar.label} + +
+ ); + })} +
+
+ {legend.map((item) => ( +
+
+ + {item.label} + +
+ ))} +
+
+ ); +} + +export function InsightCardBody({ + content, + cardIndex, + viewMode = "desktop", + isExpanded = false, +}: { + content: InsightCardContent; + cardIndex: number; + viewMode?: ViewMode; + isExpanded?: boolean; +}) { + const { data } = useDashboardData(); + const cardData = data.insightCards[cardIndex] ?? data.insightCards[0]; + + if (content.type === "kpi") { + return ; + } + if (content.chartType === "horizontal-bars") + return ( + + ); + if (content.chartType === "donut") return ; + if (content.chartType === "sparkline") return ; + if (content.chartType === "stacked-bar") + return ( + + ); + return ; +}