fix: complete Velocity-OS feature migration wiring

This commit is contained in:
2026-05-02 22:42:26 +05:30
parent 8b2d836589
commit 600716a69d
19 changed files with 1050 additions and 123 deletions

View File

@@ -15,15 +15,43 @@ Velocity-OS/
|-- infrastructure/ Deployment, model hydration, gateway, and runtime operations
```
## The Three Pillars
## Primary Surfaces
| Pillar | Route | What It Replaces From Project_Velocity |
| --- | --- | --- |
| Command | `/command` | Dashboard, Oracle prompt surface, priority signals |
| Oracle | `/oracle` | Full Oracle canvas/chat workspace, chat recents, SQL-backed result cards |
| Pipeline | `/pipeline` and `/pipeline/:personId` | CRM, leads, Client 360, conversations, tasks, showroom flow |
| Studio | `/studio` and `/studio/:propertyId` | Inventory, property pages, media gallery, Dream Weaver / Reimagine, campaigns |
| Control Room | `/control-room/:panel?` | Admin settings, Oracle/schema controls, comms settings, users, model hydration |
## Project_Velocity To Velocity-OS Migration Matrix
| Project_Velocity capability | Velocity-OS location | Migration state | Notes |
| --- | --- | --- | --- |
| Login, session, current user, guarded routes | `webos/src/auth`, `core/api/api/routes_auth.py` | Migrated | Uses token-backed authenticated shell. |
| Dashboard KPIs and priority signals | `/command` | Migrated | Command keeps the old dashboard intent but removes low-value page sprawl. |
| Lightweight Oracle ask box | `/command` | Migrated | The prompt bar now submits to Oracle and saves a chat session. |
| Full Oracle Canvas workspace | `/oracle` | Migrated | Added as a first-class nav item so the chat/canvas surface is no longer orphaned. |
| Oracle recents, new chat, search, share/export | `/oracle` | Migrated locally | Recents persist in browser storage; share currently exports chat JSON to clipboard. |
| Oracle natural DB query | `core/oracle/oracle/natural_db_agent.py`, `/api/oracle/query` | Migrated | Backend remains the source of truth for SQL-backed CRM answers. |
| Oracle JSON codebook and component templates | `core/oracle/oracle/*.json` | Migrated | JSON is not counted as JavaScript in repo language stats; absence of a JS bar does not mean the codebook is missing. |
| Oracle schema catalog and data health | `core/api/api/routes_oracle.py` | Migrated | Exposed for diagnostics and planner context. |
| Canvas revisions/share/fork server workflow | Legacy Oracle backend plus new `/oracle` shell | Partial | Backend concepts exist; new Velocity-OS UI currently prioritizes chat/result generation and local export. |
| CRM pipeline board | `/pipeline` board view | Migrated | Uses canonical CRM API and normalized response shapes. |
| CRM pipeline list | `/pipeline` list view | Migrated | Board/list now render different layouts from the same normalized data. |
| Client 360 profile | `/pipeline/:personId` | Migrated | Uses tolerant ID and response normalization for old/new backend shapes. |
| Conversations, intelligence, properties, tasks | `/pipeline/:personId` tabs | Migrated | Consolidates old CRM/comms/intelligence surfaces into one entity page. |
| Showroom/webcam mode | `/pipeline/:personId` showroom action | Migrated | Preserves the Project_Velocity video/webcam concept inside Client 360. |
| WhatsApp/comms provider config | `/control-room/comms-config` plus Client 360 | Partial | UI and API wiring are present; live send/record/transcribe depends on configured provider credentials. |
| Inventory properties | `/studio` properties tab | Migrated | Uses Studio API normalization. |
| Property detail and media gallery | `/studio/:propertyId` | Migrated | Includes floorplan, media, overview, and actions. |
| Dream Weaver / Reimagine | `/studio/:propertyId` Reimagine | Migrated | Supports image upload, prompt text, async job polling, generated preview, open, and download. |
| Campaigns / Meta ads | `/studio` campaigns tab and `/control-room/meta` | Partial | Campaign list UI/API exists; Meta execution depends on live integration credentials. |
| Control Room admin panels | `/control-room/:panel?` | Migrated | Reworked to match the dark Velocity-OS theme and current L4/SGLang runtime truth. |
| Runtime LLM/SGLang operational truth | Control Room model hydration and docs | Migrated as ops surface | Heavy model runtime remains backend/infrastructure, not a WebOS feature page. |
| iPad/mobile backend support | `core/api` shared endpoints | API migrated | Native iPad client consumes the same backend contracts; not duplicated as a WebOS page. |
## Runtime Truths
- The public app is served from `https://velocity.desineuron.in/`.
@@ -146,4 +174,3 @@ Run before handoff:
- The production build still warns that the Three.js vendor chunk is large. This is not a functional failure, but Studio 3D/media should remain lazy-loaded and can be split further later.
- The app intentionally keeps Project_Velocity as a source/reference repository. Velocity-OS should import only the required code/data assets, not blindly mirror the old structure.

View File

@@ -9,6 +9,7 @@ import { lazy, Suspense } from 'react';
import { PillarSkeleton } from './shared/layout/PillarSkeleton';
const CommandPillar = lazy(() => import('./pillars/command/CommandPillar'));
const OracleWorkspacePage = lazy(() => import('./pillars/command/OracleWorkspacePage'));
const PipelinePillar = lazy(() => import('./pillars/pipeline/PipelinePillar'));
const Client360 = lazy(() => import('./pillars/pipeline/client360/Client360'));
const ShowroomMode = lazy(() => import('./pillars/pipeline/ShowroomMode'));
@@ -47,6 +48,10 @@ const router = createBrowserRouter([
path: 'command',
element: <Lazy><CommandPillar /></Lazy>,
},
{
path: 'oracle',
element: <Lazy><OracleWorkspacePage /></Lazy>,
},
// Pillar 2: PIPELINE — Deal Intelligence (CRM + Comms + Sentinel)
{

View File

@@ -1,39 +1,16 @@
import { motion, AnimatePresence } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { useLocation, useNavigate } from 'react-router-dom';
import styles from './ControlRoom.module.css';
/**
* ControlRoom
* Admin-only surface. Accessible at /control-room.
* Protected by AdminGuard at router level AND FastAPI RBAC middleware.
*
* Panels (left sidebar → content area):
* System Health — service status, GPU utilization, queue depths
* Oracle Admin — schema catalog, canvas management, MCP tools
* Comms Config — WAHA/Evolution provider, webhook setup
* Users & Roles — broker roster, role assignment, audit log
* Model Hydration — s5cmd sync status, model inventory
* Meta Integration — OAuth, ad account, Lookalike sync
*
* Design: deliberately more "operator" than "broker" — still glassmorphic
* but denser information, monospace for technical values.
*/
type Panel =
| 'system'
| 'oracle-admin'
| 'comms-config'
| 'users'
| 'models'
| 'meta';
type Panel = 'system' | 'oracle-admin' | 'comms-config' | 'users' | 'models' | 'meta';
const PANELS: { id: Panel; label: string; icon: string }[] = [
{ id: 'system', label: 'System Health', icon: '' },
{ id: 'oracle-admin', label: 'Oracle Admin', icon: '' },
{ id: 'comms-config', label: 'Comms Config', icon: '' },
{ id: 'users', label: 'Users & Roles', icon: '' },
{ id: 'models', label: 'Model Hydration', icon: '' },
{ id: 'meta', label: 'Meta Integration', icon: '' },
{ id: 'system', label: 'System Health', icon: 'S' },
{ id: 'oracle-admin', label: 'Oracle Admin', icon: 'O' },
{ id: 'comms-config', label: 'Comms Config', icon: 'C' },
{ id: 'users', label: 'Users & Roles', icon: 'U' },
{ id: 'models', label: 'Model Hydration', icon: 'M' },
{ id: 'meta', label: 'Meta Integration', icon: 'A' },
];
export default function ControlRoom() {
@@ -44,10 +21,9 @@ export default function ControlRoom() {
return (
<div className={styles.root}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.headerIcon}></span>
<span className={styles.headerIcon}>CR</span>
<div>
<h1 className={styles.title}>Control Room</h1>
<p className={styles.subtitle}>System Administration · Admin Access Only</p>
@@ -59,7 +35,6 @@ export default function ControlRoom() {
</div>
<div className={styles.body}>
{/* Left sidebar */}
<nav className={styles.sidebar}>
{PANELS.map(({ id, label, icon }) => (
<button
@@ -81,7 +56,6 @@ export default function ControlRoom() {
))}
</nav>
{/* Panel content */}
<main className={styles.content}>
<AnimatePresence mode="wait">
<motion.div
@@ -92,12 +66,12 @@ export default function ControlRoom() {
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={styles.panelWrap}
>
{active === 'system' && <SystemHealthPanel />}
{active === 'oracle-admin' && <OracleAdminPanel />}
{active === 'comms-config' && <CommsConfigPanel />}
{active === 'users' && <UsersPanel />}
{active === 'models' && <ModelHydrationPanel />}
{active === 'meta' && <MetaIntegrationPanel />}
{active === 'system' && <SystemHealthPanel />}
{active === 'oracle-admin' && <OracleAdminPanel />}
{active === 'comms-config' && <CommsConfigPanel />}
{active === 'users' && <UsersPanel />}
{active === 'models' && <ModelHydrationPanel />}
{active === 'meta' && <MetaIntegrationPanel />}
</motion.div>
</AnimatePresence>
</main>
@@ -106,21 +80,20 @@ export default function ControlRoom() {
);
}
// ── System Health ─────────────────────────────────────────────
function SystemHealthPanel() {
const services = [
{ name: 'core-api', status: 'healthy', latency: '42ms', replicas: '2/2' },
{ name: 'webos', status: 'healthy', latency: '', replicas: '2/2' },
{ name: 'media-engine', status: 'healthy', latency: '', replicas: '1/1' },
{ name: 'postgres', status: 'healthy', latency: '3ms', replicas: '1/1' },
{ name: 'redis', status: 'healthy', latency: '0.4ms', replicas: '1/1' },
{ name: 'core-api', status: 'healthy', latency: '42ms', replicas: '2/2' },
{ name: 'webos', status: 'healthy', latency: '-', replicas: '2/2' },
{ name: 'media-engine', status: 'healthy', latency: '-', replicas: '1/1' },
{ name: 'postgres', status: 'healthy', latency: '3ms', replicas: '1/1' },
{ name: 'redis', status: 'healthy', latency: '0.4ms', replicas: '1/1' },
];
return (
<div className={styles.panel}>
<h2 className={styles.panelTitle}>System Health</h2>
<div className={styles.serviceGrid}>
{services.map(svc => (
{services.map((svc) => (
<div key={svc.name} className={`${styles.svcCard} glass-card`}>
<div className={styles.svcTop}>
<span
@@ -131,25 +104,24 @@ function SystemHealthPanel() {
</div>
<div className={styles.svcMeta}>
<span>Replicas: <code>{svc.replicas}</code></span>
{svc.latency !== '' && <span>Latency: <code>{svc.latency}</code></span>}
{svc.latency !== '-' && <span>Latency: <code>{svc.latency}</code></span>}
</div>
</div>
))}
</div>
{/* GPU MIG status */}
<div className={`${styles.gpuCard} glass-card`}>
<h3 className={styles.subTitle}>GPU · RTX 6000 Blackwell · MIG Active</h3>
<h3 className={styles.subTitle}>GPU · 4 x NVIDIA L4 · Shared runtime plane</h3>
<div className={styles.migSlices}>
<div className={styles.migSlice}>
<span className={styles.migLabel}>Slice 0 · 48GB</span>
<span className={styles.migLabel}>LLM runtime</span>
<code className={styles.migService}>SGLang Qwen3.6 35B</code>
<span className={styles.migStatus} style={{ color: 'var(--color-green)' }}> Loaded</span>
<span className={styles.migStatus} style={{ color: 'var(--color-green)' }}>Loaded</span>
</div>
<div className={styles.migSlice}>
<span className={styles.migLabel}>Slice 1 · 48GB</span>
<code className={styles.migService}>ComfyUI Wan 2.2 + Qwen-Image</code>
<span className={styles.migStatus} style={{ color: 'var(--color-green)' }}> Loaded</span>
<span className={styles.migLabel}>Media runtime</span>
<code className={styles.migService}>ComfyUI Dream Weaver</code>
<span className={styles.migStatus} style={{ color: 'var(--color-green)' }}>Loaded</span>
</div>
</div>
</div>
@@ -157,19 +129,17 @@ function SystemHealthPanel() {
);
}
// ── Oracle Admin ──────────────────────────────────────────────
function OracleAdminPanel() {
return (
<div className={styles.panel}>
<h2 className={styles.panelTitle}>Oracle Administration</h2>
<p className={styles.panelSubtitle}>
Canvas management, schema catalog, and MCP tools.
These controls are not visible to broker-role users.
Canvas management, schema catalog, data health, and component codebook controls.
</p>
<div className={styles.adminSection}>
<h3 className={styles.subTitle}>Active Canvas Sessions</h3>
<p className={styles.muted}>Query canvas history, fork/merge, and revision management available here.</p>
<button className="btn-ghost">Open Canvas Manager</button>
<p className={styles.muted}>Query canvas history, fork/merge, and revision management from the Oracle page.</p>
<button className="btn-ghost" onClick={() => window.location.assign('/oracle')}>Open Oracle Workspace</button>
</div>
<div className={styles.adminSection}>
<h3 className={styles.subTitle}>Schema Catalog</h3>
@@ -177,21 +147,18 @@ function OracleAdminPanel() {
<button className="btn-ghost">Run Data Health Check </button>
</div>
<div className={styles.adminSection}>
<h3 className={styles.subTitle}>MCP Tools</h3>
<button className="btn-ghost">View Registered Tools </button>
<h3 className={styles.subTitle}>Component Codebook</h3>
<p className={styles.muted}>Project Velocity JSON schemas are loaded from the backend Oracle codebook assets.</p>
</div>
</div>
);
}
// ── Comms Config ──────────────────────────────────────────────
function CommsConfigPanel() {
return (
<div className={styles.panel}>
<h2 className={styles.panelTitle}>Comms Configuration</h2>
<p className={styles.panelSubtitle}>
WhatsApp provider setup. Never visible to sales brokers.
</p>
<p className={styles.panelSubtitle}>WhatsApp provider setup. Never visible to broker-role users.</p>
<div className={styles.formGroup}>
<label className={styles.formLabel}>Provider</label>
<select className={styles.formSelect}>
@@ -212,9 +179,7 @@ function CommsConfigPanel() {
);
}
// ── Users & Roles ─────────────────────────────────────────────
function UsersPanel() {
const roles = ['ADMIN', 'SALES_DIRECTOR', 'SALES_BROKER'];
return (
<div className={styles.panel}>
<h2 className={styles.panelTitle}>Users & Roles</h2>
@@ -241,18 +206,17 @@ function UsersPanel() {
);
}
// ── Model Hydration ───────────────────────────────────────────
function ModelHydrationPanel() {
const models = [
{ name: 'Wan 2.2', size: '15 GB', status: 'synced', path: '/opt/models/comfy/wan2.2' },
{ name: 'Qwen-Image 2512', size: '20 GB', status: 'synced', path: '/opt/models/comfy/qwen-image-2512' },
{ name: 'Qwen3.6 35B A3B', size: '70 GB', status: 'synced', path: '/opt/models/llm/qwen3.6-35b-a3b' },
{ name: 'Wan / Dream Weaver media stack', size: 'GPU NVMe', status: 'synced', path: '/opt/dlami/nvme/models/comfy' },
{ name: 'Qwen3.6 35B A3B', size: 'GPU NVMe', status: 'synced', path: '/opt/dlami/nvme/models/Qwen-Qwen3.6-35B-A3B-FP8' },
{ name: 'Oracle component codebook', size: 'local', status: 'loaded', path: 'core/oracle/oracle/oracle_runtime_codebook_merged.json' },
];
return (
<div className={styles.panel}>
<h2 className={styles.panelTitle}>Model Hydration</h2>
<p className={styles.panelSubtitle}>NVMe-backed model store. Re-sync from S3 via s5cmd.</p>
{models.map(m => (
<p className={styles.panelSubtitle}>NVMe-backed model store and backend component assets.</p>
{models.map((m) => (
<div key={m.name} className={`${styles.modelRow} glass-card`}>
<div>
<span className={styles.modelName}>{m.name}</span>
@@ -260,29 +224,20 @@ function ModelHydrationPanel() {
</div>
<div className={styles.modelRight}>
<span className={styles.modelSize}>{m.size}</span>
<span style={{ color: 'var(--color-green)', fontSize: 'var(--text-xs)' }}>
{m.status}
</span>
<button className="btn-ghost">Re-sync</button>
<span style={{ color: 'var(--color-green)', fontSize: 'var(--text-xs)' }}>{m.status}</span>
<button className="btn-ghost">Inspect</button>
</div>
</div>
))}
<button className="btn-ghost" style={{ marginTop: 'var(--space-4)' }}>
Run Full Hydration
</button>
</div>
);
}
// ── Meta Integration ──────────────────────────────────────────
function MetaIntegrationPanel() {
return (
<div className={styles.panel}>
<h2 className={styles.panelTitle}>Meta Business Integration</h2>
<p className={styles.panelSubtitle}>
OAuth, Ad Account, and Lookalike Audience configuration.
Never visible to broker-role users.
</p>
<p className={styles.panelSubtitle}>OAuth, Ad Account, and Lookalike Audience configuration.</p>
<div className={styles.metaStatus}>
<span className={styles.statusDot} style={{ background: 'var(--color-amber)' }} />
<span>OAuth not connected</span>

View File

@@ -10,3 +10,6 @@
.clearBtn { background: none; border: none; color: var(--color-text-tertiary); cursor: pointer; font-size: 14px; padding: var(--space-1); border-radius: var(--radius-sm); }
.clearBtn:hover { color: var(--color-text-primary); }
.placeholderCard { height: 120px; border-radius: var(--radius-xl); }
.resultActions { display: flex; justify-content: flex-end; }
.openOracleBtn { border: 1px solid rgba(124,58,237,0.34); border-radius: 999px; background: rgba(124,58,237,0.12); color: var(--color-text-primary); padding: var(--space-2) var(--space-4); cursor: pointer; font-weight: var(--font-semibold); box-shadow: 0 0 20px rgba(124,58,237,0.12); }
.openOracleBtn:hover { border-color: rgba(124,58,237,0.62); background: rgba(124,58,237,0.20); }

View File

@@ -1,10 +1,38 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { api } from '@/shared/lib/apiClient';
import { unwrapArray, unwrapObject } from '@/shared/lib/apiShape';
import { OracleResultCard } from './OracleResultCard';
import styles from './OracleBar.module.css';
type QueryState = 'idle' | 'thinking' | 'result';
type OracleEnvelope = { status?: string; data?: unknown; detail?: string; message?: string; error?: string };
type OracleRow = Record<string, unknown>;
function normalizeOracleResult(payload: unknown) {
const root = unwrapObject<Record<string, unknown>>(payload) ?? {};
const rows = unwrapArray<OracleRow>(root, ['rows', 'dataRows']);
const components = unwrapArray<Record<string, unknown>>(root, ['components']);
const firstComponent = components[0];
const componentRows = rows.length > 0 ? rows : unwrapArray<OracleRow>(firstComponent, ['rows', 'dataRows']);
const columns = Array.isArray(root.columns)
? root.columns.filter((item): item is string => typeof item === 'string')
: componentRows[0]
? Object.keys(componentRows[0])
: [];
return {
...root,
title: root.title ?? firstComponent?.title,
summary: root.summary ?? firstComponent?.summary,
rows: componentRows,
columns,
rowCount: typeof root.rowCount === 'number' ? root.rowCount : componentRows.length,
componentType: root.componentType ?? firstComponent?.type ?? firstComponent?.componentType,
sourceTables: Array.isArray(root.sourceTables) ? root.sourceTables : [],
};
}
/**
* OracleBar
@@ -25,7 +53,74 @@ const SUGGESTIONS = [
'What is the average QD score by project?',
];
const NUMBER_WORDS: Record<string, number> = {
one: 1,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
eleven: 11,
twelve: 12,
fifteen: 15,
twenty: 20,
thirty: 30,
fifty: 50,
};
const ORACLE_CHAT_STORAGE_KEY = 'velocity-oracle-chat-sessions-v1';
function createChatSessionFromCommand(prompt: string, result: any) {
const now = new Date().toISOString();
const title = prompt.trim().length > 58 ? `${prompt.trim().slice(0, 55)}...` : prompt.trim();
const session = {
id: `oracle_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
title: title || 'Oracle Chat',
createdAt: now,
updatedAt: now,
turns: [{
id: `turn_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
prompt,
createdAt: now,
status: result?.title === 'Oracle query failed' ? 'failed' : 'done',
result,
error: result?.title === 'Oracle query failed' ? result?.summary : undefined,
}],
};
try {
const current = JSON.parse(localStorage.getItem(ORACLE_CHAT_STORAGE_KEY) ?? '[]');
const sessions = Array.isArray(current) ? current : [];
localStorage.setItem(ORACLE_CHAT_STORAGE_KEY, JSON.stringify([session, ...sessions].slice(0, 30)));
} catch {
localStorage.setItem(ORACLE_CHAT_STORAGE_KEY, JSON.stringify([session]));
}
}
function deriveRowLimit(prompt: string): number {
const lowered = prompt.toLowerCase();
const numericMatch = lowered.match(/\b(?:top|last|first|lowest|highest|best|worst)\s+(\d{1,3})\b|\b(\d{1,3})\s+(?:clients|leads|properties|projects|records|rows)\b/);
const numericLimit = Number(numericMatch?.[1] ?? numericMatch?.[2]);
if (Number.isFinite(numericLimit) && numericLimit > 0) {
return Math.min(100, Math.max(1, numericLimit));
}
for (const [word, value] of Object.entries(NUMBER_WORDS)) {
const phrase = new RegExp(`\\b(?:top|last|first|lowest|highest|best|worst)\\s+${word}\\b|\\b${word}\\s+(?:clients|leads|properties|projects|records|rows)\\b`);
if (phrase.test(lowered)) {
return value;
}
}
return 25;
}
export function OracleBar() {
const navigate = useNavigate();
const [queryState, setQueryState] = useState<QueryState>('idle');
const [query, setQuery] = useState('');
const [isFocused, setIsFocused] = useState(false);
@@ -45,25 +140,25 @@ export function OracleBar() {
setResult(null);
try {
const resp = await fetch('/api/oracle/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: query, context: { surface: 'command_pillar' } }),
const payload = await api.post<OracleEnvelope>('/oracle/query', {
prompt: query,
row_limit: deriveRowLimit(query),
context: { surface: 'command_pillar' },
});
const payload = await resp.json().catch(() => ({})) as OracleEnvelope;
if (!resp.ok) {
throw new Error(payload.detail || payload.message || payload.error || `HTTP ${resp.status}`);
}
setResult(payload.data ?? payload);
const normalized = normalizeOracleResult(payload.data ?? payload);
setResult(normalized);
createChatSessionFromCommand(query, normalized);
setQueryState('result');
} catch (error) {
setResult({
const failed = {
title: 'Oracle query failed',
summary: error instanceof Error ? error.message : 'Unable to run Oracle query.',
rows: [],
columns: [],
warnings: ['Check the Oracle API route and backend logs.'],
});
};
setResult(failed);
createChatSessionFromCommand(query, failed);
setQueryState('result');
}
}, [query, queryState]);
@@ -173,7 +268,14 @@ export function OracleBar() {
{/* ── Result card ──────────────────────────────────── */}
{queryState === 'result' && result && (
<OracleResultCard result={result} query={query} />
<>
<div className={styles.resultActions}>
<button type="button" className={styles.openOracleBtn} onClick={() => navigate('/oracle')}>
Open full Oracle chat
</button>
</div>
<OracleResultCard result={result} query={query} />
</>
)}
</AnimatePresence>
</div>

View File

@@ -0,0 +1,337 @@
.shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
min-height: 540px;
border: var(--glass-border);
border-radius: 28px;
overflow: hidden;
background:
radial-gradient(circle at 20% 0%, rgba(124, 58, 237, 0.16), transparent 34%),
linear-gradient(145deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.025));
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.34), 0 0 44px rgba(124, 58, 237, 0.08);
}
.rail {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5);
border-right: var(--glass-border);
background: rgba(0, 0, 0, 0.16);
}
.eyebrow {
margin: 0 0 var(--space-1);
color: var(--color-violet-light);
font-size: 10px;
font-weight: var(--font-bold);
letter-spacing: 0.28em;
text-transform: uppercase;
}
.title {
margin: 0;
color: var(--color-text-primary);
font-size: var(--text-xl);
font-weight: var(--font-bold);
}
.caption {
margin: var(--space-1) 0 0;
color: var(--color-text-tertiary);
font-size: var(--text-xs);
line-height: 1.55;
}
.railButton,
.sessionButton,
.searchInput,
.shareButton {
width: 100%;
border: var(--glass-border);
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.045);
color: var(--color-text-secondary);
}
.railButton,
.shareButton {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 44px;
cursor: pointer;
font-weight: var(--font-semibold);
transition: border-color var(--duration-fast), box-shadow var(--duration-fast), color var(--duration-fast), background var(--duration-fast);
}
.railButton:hover,
.shareButton:hover {
border-color: rgba(124, 58, 237, 0.48);
background: rgba(124, 58, 237, 0.13);
color: var(--color-text-primary);
box-shadow: 0 0 26px rgba(124, 58, 237, 0.16);
}
.primaryButton {
color: white;
border-color: rgba(124, 58, 237, 0.55);
background: linear-gradient(135deg, rgba(124, 58, 237, 0.85), rgba(59, 130, 246, 0.55));
}
.searchInput {
min-height: 42px;
padding: 0 var(--space-4);
outline: none;
font: inherit;
}
.searchInput:focus {
border-color: rgba(124, 58, 237, 0.55);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.16);
}
.sectionLabel {
margin: var(--space-2) 0 0;
color: var(--color-text-tertiary);
font-size: 10px;
font-weight: var(--font-bold);
letter-spacing: 0.22em;
text-transform: uppercase;
}
.sessions {
display: flex;
flex-direction: column;
gap: var(--space-2);
overflow: auto;
min-height: 0;
}
.sessionButton {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
cursor: pointer;
text-align: left;
}
.sessionButton:hover,
.sessionActive {
border-color: rgba(59, 130, 246, 0.42);
background: rgba(59, 130, 246, 0.11);
box-shadow: 0 0 28px rgba(59, 130, 246, 0.14);
}
.sessionGlyph {
width: 34px;
height: 34px;
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 50%;
border: 1px solid rgba(124, 58, 237, 0.35);
background: rgba(124, 58, 237, 0.16);
color: var(--color-text-primary);
font-weight: var(--font-bold);
}
.sessionMeta {
min-width: 0;
}
.sessionTitle {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-primary);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
}
.sessionSub {
margin-top: 3px;
color: var(--color-text-tertiary);
font-size: 11px;
}
.emptySessions {
padding: var(--space-4);
color: var(--color-text-tertiary);
font-size: var(--text-sm);
}
.main {
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-width: 0;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
border-bottom: var(--glass-border);
}
.topbarTitle {
margin: 0;
color: var(--color-text-primary);
font-size: var(--text-lg);
font-weight: var(--font-bold);
}
.topbarSub {
margin: 3px 0 0;
color: var(--color-text-tertiary);
font-size: var(--text-xs);
}
.statusPill {
padding: var(--space-2) var(--space-3);
border-radius: 999px;
border: 1px solid rgba(16, 185, 129, 0.24);
background: rgba(16, 185, 129, 0.09);
color: #86efac;
font-size: 11px;
font-weight: var(--font-bold);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.thread {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-6);
overflow: auto;
}
.emptyCanvas {
margin: auto;
max-width: 430px;
text-align: center;
color: var(--color-text-secondary);
}
.emptyIcon {
width: 72px;
height: 72px;
display: grid;
place-items: center;
margin: 0 auto var(--space-4);
border-radius: 24px;
border: 1px solid rgba(124, 58, 237, 0.22);
background: rgba(124, 58, 237, 0.10);
box-shadow: 0 0 34px rgba(124, 58, 237, 0.16);
color: var(--color-violet-light);
font-size: 26px;
}
.emptyCanvas h3 {
margin: 0 0 var(--space-2);
color: var(--color-text-primary);
}
.turn {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.userBubble {
align-self: flex-end;
max-width: min(780px, 88%);
padding: var(--space-3) var(--space-4);
border-radius: 20px 20px 6px 20px;
background: linear-gradient(135deg, rgba(124, 58, 237, 0.28), rgba(59, 130, 246, 0.16));
border: 1px solid rgba(124, 58, 237, 0.28);
color: var(--color-text-primary);
}
.thinkingCard {
min-height: 120px;
border-radius: var(--radius-xl);
}
.composer {
padding: var(--space-5) var(--space-6);
border-top: var(--glass-border);
background: rgba(0, 0, 0, 0.14);
}
.composerInner {
display: flex;
align-items: flex-end;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid rgba(124, 58, 237, 0.28);
border-radius: 24px;
background: rgba(255, 255, 255, 0.055);
box-shadow: 0 0 34px rgba(124, 58, 237, 0.12);
}
.composerInput {
flex: 1;
min-height: 38px;
max-height: 130px;
resize: vertical;
border: 0;
outline: 0;
background: transparent;
color: var(--color-text-primary);
font: inherit;
line-height: 1.5;
}
.composerInput::placeholder {
color: var(--color-text-tertiary);
}
.sendButton {
width: 42px;
height: 42px;
display: grid;
place-items: center;
flex: 0 0 auto;
border: 0;
border-radius: 16px;
background: linear-gradient(135deg, var(--color-violet), #2563eb);
color: white;
cursor: pointer;
font-size: 18px;
box-shadow: 0 0 26px rgba(124, 58, 237, 0.36);
}
.sendButton:disabled {
cursor: not-allowed;
opacity: 0.45;
box-shadow: none;
}
.notice {
margin-top: var(--space-2);
color: var(--color-text-tertiary);
font-size: 11px;
}
@media (max-width: 980px) {
.shell {
grid-template-columns: 1fr;
}
.rail {
border-right: 0;
border-bottom: var(--glass-border);
}
.sessions {
max-height: 220px;
}
}

View File

@@ -0,0 +1,341 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { api } from '../../shared/lib/apiClient';
import { unwrapArray, unwrapObject } from '../../shared/lib/apiShape';
import { OracleResultCard } from './OracleResultCard';
import styles from './OracleChatConsole.module.css';
type OracleRow = Record<string, unknown>;
interface OracleResult {
title?: string;
summary?: string;
columns?: string[];
rows?: OracleRow[];
rowCount?: number;
componentType?: string;
warnings?: string[];
sourceTables?: string[];
}
interface OracleTurn {
id: string;
prompt: string;
createdAt: string;
status: 'thinking' | 'done' | 'failed';
result?: OracleResult;
error?: string;
}
interface OracleSession {
id: string;
title: string;
createdAt: string;
updatedAt: string;
turns: OracleTurn[];
}
const STORAGE_KEY = 'velocity-oracle-chat-sessions-v1';
function makeId(prefix: string) {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function createSession(title = 'New Oracle Chat'): OracleSession {
const now = new Date().toISOString();
return {
id: makeId('oracle'),
title,
createdAt: now,
updatedAt: now,
turns: [],
};
}
function deriveTitle(prompt: string) {
const clean = prompt.trim().replace(/\s+/g, ' ');
return clean.length > 58 ? `${clean.slice(0, 55)}...` : clean || 'Oracle Chat';
}
function normalizeResult(raw: unknown, prompt: string): OracleResult {
const body = unwrapObject<Record<string, unknown>>(raw) ?? {};
const data = unwrapObject<Record<string, unknown>>(body.data ?? body.result ?? body.payload ?? body) ?? {};
const components = unwrapArray<Record<string, unknown>>(data.components ?? data.componentPlan ?? data.canvasComponents);
const firstComponent = unwrapObject<Record<string, unknown>>(components[0]) ?? {};
const rows = unwrapArray<OracleRow>(data.rows ?? data.dataRows ?? firstComponent.dataRows ?? firstComponent.rows);
const columns = unwrapArray<unknown>(data.columns ?? firstComponent.columns);
const sourceTables = unwrapArray<unknown>(data.sourceTables ?? data.source_tables ?? firstComponent.sourceTables);
const warnings = unwrapArray<unknown>(data.warnings ?? firstComponent.warnings);
return {
title: String(data.title ?? firstComponent.title ?? `Oracle result for "${prompt}"`),
summary: String(data.summary ?? firstComponent.summary ?? data.sqlSummary ?? ''),
rows,
columns: columns.length ? columns.map(String) : rows[0] ? Object.keys(rows[0]) : [],
rowCount: Number(data.rowCount ?? data.row_count ?? rows.length),
componentType: String(data.componentType ?? data.component_type ?? firstComponent.type ?? firstComponent.componentType ?? 'table'),
warnings: warnings.map(String),
sourceTables: sourceTables.map(String),
};
}
function deriveRowLimit(prompt: string) {
const numeric = prompt.match(/\b(?:top|last|first|lowest|highest)?\s*(\d{1,3})\b/i);
if (numeric) return Math.min(Math.max(Number(numeric[1]), 1), 100);
const words: Record<string, number> = {
one: 1,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
};
const word = prompt.toLowerCase().match(/\b(one|two|three|four|five|six|seven|eight|nine|ten)\b/);
return word ? words[word[1]] : 10;
}
function relativeTime(value: string) {
const delta = Date.now() - new Date(value).getTime();
const minutes = Math.max(1, Math.floor(delta / 60000));
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function loadSessions(): OracleSession[] {
try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]');
return Array.isArray(parsed) && parsed.length ? parsed : [createSession('Oracle Main Canvas')];
} catch {
return [createSession('Oracle Main Canvas')];
}
}
export default function OracleChatConsole() {
const [sessions, setSessions] = useState<OracleSession[]>(loadSessions);
const [activeId, setActiveId] = useState(() => sessions[0]?.id ?? '');
const [prompt, setPrompt] = useState('');
const [search, setSearch] = useState('');
const [shareNote, setShareNote] = useState('');
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const activeSession = useMemo(
() => sessions.find((session) => session.id === activeId) ?? sessions[0],
[activeId, sessions],
);
const filteredSessions = useMemo(() => {
const term = search.trim().toLowerCase();
const sorted = [...sessions].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
if (!term) return sorted;
return sorted.filter((session) =>
session.title.toLowerCase().includes(term)
|| session.turns.some((turn) => turn.prompt.toLowerCase().includes(term)),
);
}, [search, sessions]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions.slice(0, 30)));
}, [sessions]);
const startNewChat = useCallback(() => {
const session = createSession();
setSessions((current) => [session, ...current]);
setActiveId(session.id);
setPrompt('');
setTimeout(() => inputRef.current?.focus(), 50);
}, []);
const submitPrompt = useCallback(async () => {
const clean = prompt.trim();
if (!clean || !activeSession) return;
const turnId = makeId('turn');
const now = new Date().toISOString();
setPrompt('');
setShareNote('');
setSessions((current) => current.map((session) => {
if (session.id !== activeSession.id) return session;
const title = session.turns.length === 0 && session.title === 'New Oracle Chat'
? deriveTitle(clean)
: session.title;
return {
...session,
title,
updatedAt: now,
turns: [
...session.turns,
{ id: turnId, prompt: clean, createdAt: now, status: 'thinking' },
],
};
}));
try {
const response = await api.post('/oracle/query', {
prompt: clean,
row_limit: deriveRowLimit(clean),
context: {
surface: 'velocity_os_oracle_chat',
session_id: activeSession.id,
},
});
const result = normalizeResult(response, clean);
setSessions((current) => current.map((session) => {
if (session.id !== activeSession.id) return session;
return {
...session,
updatedAt: new Date().toISOString(),
turns: session.turns.map((turn) => (
turn.id === turnId ? { ...turn, status: 'done', result } : turn
)),
};
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Oracle request failed';
setSessions((current) => current.map((session) => {
if (session.id !== activeSession.id) return session;
return {
...session,
updatedAt: new Date().toISOString(),
turns: session.turns.map((turn) => (
turn.id === turnId ? { ...turn, status: 'failed', error: message } : turn
)),
};
}));
}
}, [activeSession, prompt]);
const shareSession = useCallback(async () => {
if (!activeSession) return;
const payload = JSON.stringify(activeSession, null, 2);
try {
await navigator.clipboard.writeText(payload);
setShareNote('Chat copied for sharing');
} catch {
setShareNote('Share export prepared in browser memory');
}
}, [activeSession]);
return (
<section className={styles.shell} aria-label="Oracle chat canvas">
<aside className={styles.rail}>
<div>
<p className={styles.eyebrow}>Oracle Canvas</p>
<h2 className={styles.title}>Ask, save, share</h2>
<p className={styles.caption}>Every search becomes a chat tab with persistent visual results.</p>
</div>
<button className={`${styles.railButton} ${styles.primaryButton}`} type="button" onClick={startNewChat}>
+ New Chat
</button>
<input
className={styles.searchInput}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search chats..."
/>
<p className={styles.sectionLabel}>Recent</p>
<div className={styles.sessions}>
{filteredSessions.map((session) => (
<button
key={session.id}
type="button"
className={`${styles.sessionButton} ${session.id === activeSession?.id ? styles.sessionActive : ''}`}
onClick={() => setActiveId(session.id)}
>
<span className={styles.sessionGlyph}>{session.title.slice(0, 1).toUpperCase()}</span>
<span className={styles.sessionMeta}>
<span className={styles.sessionTitle}>{session.title}</span>
<span className={styles.sessionSub}>{session.turns.length} prompts · {relativeTime(session.updatedAt)}</span>
</span>
</button>
))}
{!filteredSessions.length && <div className={styles.emptySessions}>No matching chats.</div>}
</div>
</aside>
<main className={styles.main}>
<div className={styles.topbar}>
<div>
<h1 className={styles.topbarTitle}>{activeSession?.title ?? 'Oracle Canvas'}</h1>
<p className={styles.topbarSub}>Natural CRM intelligence over live Velocity data</p>
</div>
<div>
<button className={styles.shareButton} type="button" onClick={shareSession}>Share / Export</button>
{shareNote && <p className={styles.notice}>{shareNote}</p>}
</div>
</div>
<div className={styles.thread}>
{!activeSession?.turns.length && (
<div className={styles.emptyCanvas}>
<div className={styles.emptyIcon}></div>
<h3>Start with a business question</h3>
<p>Example: Which clients have not been contacted in 7 days but have high QD?</p>
</div>
)}
{activeSession?.turns.map((turn) => (
<motion.article
key={turn.id}
className={styles.turn}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
>
<div className={styles.userBubble}>{turn.prompt}</div>
{turn.status === 'thinking' && <div className={`${styles.thinkingCard} glass-card skeleton-shimmer`} />}
{turn.status === 'failed' && (
<OracleResultCard
query={turn.prompt}
result={{
title: 'Oracle request failed',
summary: turn.error,
rows: [],
warnings: turn.error ? [turn.error] : [],
}}
/>
)}
{turn.status === 'done' && turn.result && (
<OracleResultCard result={turn.result} query={turn.prompt} />
)}
</motion.article>
))}
</div>
<form
className={styles.composer}
onSubmit={(event) => {
event.preventDefault();
void submitPrompt();
}}
>
<div className={styles.composerInner}>
<textarea
ref={inputRef}
className={styles.composerInput}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void submitPrompt();
}
}}
placeholder="Ask Oracle anything about clients, properties, QD, calls, visits, campaigns..."
/>
<button className={styles.sendButton} type="submit" disabled={!prompt.trim()}>
</button>
</div>
</form>
</main>
</section>
);
}

View File

@@ -0,0 +1,14 @@
.root {
min-height: 100%;
padding: var(--space-8);
}
.shellWrap {
min-height: calc(100vh - 96px);
}
@media (max-width: 900px) {
.root {
padding: var(--space-4);
}
}

View File

@@ -0,0 +1,12 @@
import OracleChatConsole from './OracleChatConsole';
import styles from './OracleWorkspacePage.module.css';
export default function OracleWorkspacePage() {
return (
<div className={styles.root}>
<div className={styles.shellWrap}>
<OracleChatConsole />
</div>
</div>
);
}

View File

@@ -7,7 +7,11 @@
.liveContent { display: flex; flex-direction: column; gap: var(--space-5); flex: 1; }
.twoCol { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-5); }
/* Silhouette */
.vizPanel { display: flex; align-items: center; justify-content: center; min-height: 240px; border-radius: var(--radius-xl); }
.vizPanel { display: flex; flex-direction: column; align-items: stretch; justify-content: center; gap: var(--space-4); min-height: 320px; border-radius: var(--radius-xl); padding: var(--space-4); }
.cameraFrame { flex: 1; min-height: 240px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-lg); overflow: hidden; background: radial-gradient(circle at 50% 30%, rgba(124,58,237,0.18), transparent 42%), rgba(0,0,0,0.28); border: var(--glass-border); }
.cameraVideo { width: 100%; height: 100%; object-fit: cover; display: block; }
.cameraActions { display: flex; align-items: center; gap: var(--space-3); justify-content: space-between; flex-wrap: wrap; }
.cameraError { font-size: var(--text-xs); color: var(--color-amber); max-width: 70%; }
.silhouetteWrap { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); }
.silhouette { width: 120px; height: 180px; opacity: 0.7; }
.zoneLabel { font-size: var(--text-xs); color: var(--color-text-tertiary); }

View File

@@ -29,7 +29,10 @@ export default function ShowroomMode() {
const navigate = useNavigate();
const [phase, setPhase] = useState<ShowroomPhase>('live');
const [elapsed, setElapsed] = useState(0); // seconds
const [cameraStream, setCameraStream] = useState<MediaStream | null>(null);
const [cameraError, setCameraError] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const {
isShowroomActive,
@@ -45,8 +48,15 @@ export default function ShowroomMode() {
timerRef.current = setInterval(() => setElapsed(s => s + 1), 1000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
cameraStream?.getTracks().forEach((track) => track.stop());
};
}, []);
}, [cameraStream]);
useEffect(() => {
if (videoRef.current && cameraStream) {
videoRef.current.srcObject = cameraStream;
}
}, [cameraStream]);
// End session → summary
const handleEndSession = () => {
@@ -57,9 +67,28 @@ export default function ShowroomMode() {
const handleExitShowroom = () => {
clearPendingAlert();
cameraStream?.getTracks().forEach((track) => track.stop());
navigate('/pipeline');
};
const handleStartCamera = async () => {
try {
setCameraError(null);
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
audio: false,
});
setCameraStream(stream);
} catch (error) {
setCameraError(error instanceof Error ? error.message : 'Camera permission was denied.');
}
};
const handleStopCamera = () => {
cameraStream?.getTracks().forEach((track) => track.stop());
setCameraStream(null);
};
const formatTime = (s: number) =>
`${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
@@ -96,7 +125,19 @@ export default function ShowroomMode() {
<div className={styles.twoCol}>
{/* Left: Anonymized silhouette visualization */}
<div className={`${styles.vizPanel} glass`}>
<SilhouetteViz zone={session?.currentZone} />
<div className={styles.cameraFrame}>
{cameraStream ? (
<video ref={videoRef} className={styles.cameraVideo} autoPlay playsInline muted />
) : (
<SilhouetteViz zone={session?.currentZone} />
)}
</div>
<div className={styles.cameraActions}>
<button className="btn-ghost" onClick={cameraStream ? handleStopCamera : () => void handleStartCamera()}>
{cameraStream ? 'Stop Camera' : 'Start Camera'}
</button>
{cameraError && <span className={styles.cameraError}>{cameraError}</span>}
</div>
</div>
{/* Right: QD Engagement */}

View File

@@ -35,9 +35,14 @@ export default function Client360() {
const [activeTab, setActiveTab] = useState<Tab>('conversations');
const { client, isLoading, error } = useClient360(personId!);
useEffect(() => {
setActiveTab('conversations');
}, [personId]);
const backToPipeline = () => {
void queryClient.invalidateQueries({ queryKey: ['kanban'] });
navigate('/pipeline', { replace: true });
navigate('/pipeline');
};
if (isLoading) return <Client360Skeleton />;
@@ -168,7 +173,7 @@ export default function Client360() {
<div className={styles.tabContent}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={activeTab}
key={`${personId}:${activeTab}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}

View File

@@ -13,7 +13,8 @@ export function useClient360(personId: string) {
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
return mapClient360(normalizeSnapshot(payload));
},
staleTime: 30_000,
staleTime: 0,
refetchOnMount: 'always',
enabled: !!personId,
});
return { client: query.data, isLoading: query.isLoading, error: query.error };
@@ -30,7 +31,8 @@ export function useConversations(personId: string) {
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
return mapConversationEvents(normalizeSnapshot(payload));
},
staleTime: 10_000,
staleTime: 0,
refetchOnMount: 'always',
enabled: !!personId,
});
@@ -51,7 +53,8 @@ export function useClientProperties(personId: string) {
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
return mapPropertyInterests(normalizeSnapshot(payload));
},
staleTime: 60_000,
staleTime: 0,
refetchOnMount: 'always',
enabled: !!personId,
});
return { properties: query.data ?? [], isLoading: query.isLoading };
@@ -68,7 +71,8 @@ export function useClientTasks(personId: string) {
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
return mapTasks(normalizeSnapshot(payload));
},
staleTime: 30_000,
staleTime: 0,
refetchOnMount: 'always',
enabled: !!personId,
});

View File

@@ -11,9 +11,9 @@ export function useKanban() {
queryKey: ['kanban'],
queryFn: async () => {
const payload = await api.get<unknown>('/crm/pipeline/kanban?limit=250&offset=0');
return unwrapArray<RawKanbanStage>(payload).map(normalizeStage);
return unwrapArray<RawKanbanStage>(payload, ['stages', 'kanban', 'columns', 'board']).map(normalizeStage);
},
staleTime: 30_000,
staleTime: 0,
refetchInterval: 60_000,
refetchOnMount: 'always',
refetchOnWindowFocus: true,
@@ -51,30 +51,49 @@ interface RawKanbanStage {
stage?: string;
status?: string;
label?: string;
name?: string;
emoji?: string;
leads?: unknown;
items?: unknown;
data?: unknown;
records?: unknown;
rows?: unknown;
}
function normalizeStage(stage: RawKanbanStage): KanbanStage {
const id = String(stage.id ?? stage.stage ?? stage.status ?? 'new');
const leads = unwrapArray<KanbanLead>(stage.leads ?? stage.items ?? stage.data).map(normalizeLead);
const leads = unwrapArray<RawKanbanLead>(stage, ['leads', 'items', 'data', 'records', 'rows']).map(normalizeLead);
return {
id,
label: String(stage.label ?? id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())),
label: String(stage.label ?? stage.name ?? id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())),
emoji: String(stage.emoji ?? id.slice(0, 1).toUpperCase()),
leads,
};
}
function normalizeLead(lead: Partial<KanbanLead>): KanbanLead {
interface RawKanbanLead extends Partial<KanbanLead> {
person_id?: string;
lead_id?: string;
client_id?: string;
full_name?: string;
client_name?: string;
project_name?: string;
projects?: string;
qd_score?: number | string;
score?: number | string;
last_contact_relative?: string;
last_contact_channel?: string;
channel?: string;
}
function normalizeLead(lead: RawKanbanLead): KanbanLead {
return {
...lead,
id: String(lead.id),
name: String(lead.name ?? 'Unnamed Client'),
qdScore: Number.isFinite(Number(lead.qdScore)) ? Number(lead.qdScore) : 0,
lastContactRelative: String(lead.lastContactRelative ?? 'No contact yet'),
lastContactChannel: String(lead.lastContactChannel ?? 'crm'),
id: String(lead.id ?? lead.person_id ?? lead.lead_id ?? lead.client_id ?? crypto.randomUUID()),
name: String(lead.name ?? lead.full_name ?? lead.client_name ?? 'Unnamed Client'),
location: lead.location ?? lead.project_name ?? lead.projects,
qdScore: Number.isFinite(Number(lead.qdScore ?? lead.qd_score ?? lead.score)) ? Number(lead.qdScore ?? lead.qd_score ?? lead.score) : 0,
lastContactRelative: String(lead.lastContactRelative ?? lead.last_contact_relative ?? 'No contact yet'),
lastContactChannel: String(lead.lastContactChannel ?? lead.last_contact_channel ?? lead.channel ?? 'crm'),
};
}

View File

@@ -19,8 +19,9 @@ export function AuthenticatedShell() {
// Determine spatial direction for pillar transitions
const getPillarIndex = (path: string): number => {
if (path.startsWith('/command')) return 0;
if (path.startsWith('/pipeline')) return 1;
if (path.startsWith('/studio')) return 2;
if (path.startsWith('/oracle')) return 1;
if (path.startsWith('/pipeline')) return 2;
if (path.startsWith('/studio')) return 3;
return -1;
};

View File

@@ -18,6 +18,12 @@ const PILLARS = [
sublabel: 'Morning Briefing',
icon: CommandIcon,
},
{
path: '/oracle',
label: 'Oracle',
sublabel: 'AI Canvas',
icon: OracleIcon,
},
{
path: '/pipeline',
label: 'Pipeline',
@@ -117,6 +123,27 @@ function CommandIcon({ active }: { active: boolean }) {
);
}
function OracleIcon({ active }: { active: boolean }) {
const c = active ? 'var(--color-violet-light)' : 'currentColor';
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M10 2.4l1.45 4.3 4.15 1.5-4.15 1.5L10 14l-1.45-4.3-4.15-1.5 4.15-1.5L10 2.4Z"
fill={active ? 'var(--color-violet)' : 'transparent'}
stroke={c}
strokeWidth="1.35"
strokeLinejoin="round"
/>
<path
d="M15.4 12.5l.55 1.55 1.55.55-1.55.55-.55 1.55-.55-1.55-1.55-.55 1.55-.55.55-1.55Z"
stroke={c}
strokeWidth="1.2"
strokeLinejoin="round"
/>
</svg>
);
}
function PipelineIcon({ active }: { active: boolean }) {
const c = active ? 'var(--color-violet-light)' : 'currentColor';
return (

View File

@@ -18,6 +18,14 @@ export function unwrapArray<T>(payload: unknown, keys: string[] = []): T[] {
if (Array.isArray(candidate)) return candidate as T[];
}
for (const key of ['data', 'result', 'payload', 'body']) {
const candidate = payload[key];
if (isRecord(candidate)) {
const nested = unwrapArray<T>(candidate, keys);
if (nested.length > 0) return nested;
}
}
return [];
}

View File

@@ -1,10 +1,25 @@
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
const ZUSTAND_AUTH_KEY = 'velocity-auth';
function getPersistedAuthToken(): string | null {
const raw = window.localStorage.getItem(ZUSTAND_AUTH_KEY);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as { state?: { token?: unknown } };
return typeof parsed.state?.token === 'string' && parsed.state.token.length > 0 ? parsed.state.token : null;
} catch {
return null;
}
}
export function getVelocityToken(): string | null {
if (typeof window === 'undefined') {
return null;
}
return window.localStorage.getItem(VELOCITY_TOKEN_KEY);
return window.localStorage.getItem(VELOCITY_TOKEN_KEY) ?? getPersistedAuthToken();
}
export function setVelocityToken(token: string) {

View File

@@ -50,6 +50,13 @@ export default defineConfig({
outDir: 'dist',
sourcemap: false, // no source maps in production build
minify: 'esbuild',
modulePreload: {
resolveDependencies(_url, deps) {
// Three.js is only needed after entering Studio/property 3D routes.
// Do not preload the heavy R3F chunk on the Command/Pipeline landing path.
return deps.filter((dep) => !dep.includes('vendor-three'));
},
},
rollupOptions: {
output: {
// Content-hash filenames for immutable caching