Feat: CRM v2, Richer synthetic data, Canvas JSON Components
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Send, Mic, Kanban, Users, Phone, MapPinned, CalendarClock, ChevronDown, History, BarChart2 } from 'lucide-react';
|
||||
import { Send, Mic, Kanban, Users, Phone, MapPinned, CalendarClock, ChevronDown, History, BarChart2, Database } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { CanvasPageRevision, MergeRequest, UserProfile } from '@/oracle/types/canvas';
|
||||
import type { ComponentRenderContext } from '@/oracle/components/ComponentRegistry';
|
||||
@@ -23,6 +23,7 @@ import { useOraclePage } from '@/oracle/hooks/useOraclePage';
|
||||
import { useOracleExecution } from '@/oracle/hooks/useOracleExecution';
|
||||
import { BranchBar } from '@/oracle/components/BranchBar';
|
||||
import { CanvasViewport } from '@/oracle/components/CanvasViewport';
|
||||
import { ClientDataLens } from '@/oracle/components/ClientDataLens';
|
||||
import { PromptRail } from '@/oracle/components/PromptRail';
|
||||
import { ShareModal } from '@/oracle/components/ShareModal';
|
||||
import { RollbackConfirmModal } from '@/oracle/components/RollbackConfirmModal';
|
||||
@@ -68,6 +69,7 @@ export default function OraclePage() {
|
||||
const [listening, setListening] = useState(false);
|
||||
const [railOpen, setRailOpen] = useState(false);
|
||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||
const [activeSubtab, setActiveSubtab] = useState<'canvas' | 'client-data'>('canvas');
|
||||
|
||||
// Overlay state
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
@@ -229,8 +231,31 @@ export default function OraclePage() {
|
||||
onOpenMergeReview={() => void handleOpenMergeReview()}
|
||||
/>
|
||||
|
||||
<div className="relative z-20 flex flex-shrink-0 gap-2 px-5 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSubtab('canvas')}
|
||||
className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs font-semibold transition ${
|
||||
activeSubtab === 'canvas' ? 'border-blue-400/35 bg-blue-500/15 text-blue-100' : 'border-white/10 bg-white/[0.03] text-zinc-500 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<BarChart2 className="h-3.5 w-3.5" />
|
||||
Oracle Canvas
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSubtab('client-data')}
|
||||
className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs font-semibold transition ${
|
||||
activeSubtab === 'client-data' ? 'border-cyan-400/35 bg-cyan-500/15 text-cyan-100' : 'border-white/10 bg-white/[0.03] text-zinc-500 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
Client Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── AI Insight strip ───────────────────────────────────────────────── */}
|
||||
{page && history.length > 0 && history[history.length - 1].execution.summary && (
|
||||
{activeSubtab === 'canvas' && page && history.length > 0 && history[history.length - 1].execution.summary && (
|
||||
<div className="relative z-10 px-4 pt-2 pb-1.5 flex-shrink-0">
|
||||
<div
|
||||
className="flex items-stretch rounded-xl overflow-hidden"
|
||||
@@ -253,6 +278,10 @@ export default function OraclePage() {
|
||||
|
||||
{/* ── Main content area: canvas + rail ───────────────────────────────── */}
|
||||
<div className="relative z-10 flex-1 flex overflow-hidden min-h-0">
|
||||
{activeSubtab === 'client-data' ? (
|
||||
<ClientDataLens />
|
||||
) : (
|
||||
<>
|
||||
{/* Canvas viewport */}
|
||||
<CanvasViewport
|
||||
components={components}
|
||||
@@ -269,10 +298,12 @@ export default function OraclePage() {
|
||||
isOpen={railOpen}
|
||||
onToggle={() => setRailOpen((p) => !p)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Floating Prompt Bar ─────────────────────────────────────────────── */}
|
||||
<div className="absolute inset-x-0 bottom-8 z-30 flex justify-center px-4">
|
||||
{activeSubtab === 'canvas' && <div className="absolute inset-x-0 bottom-8 z-30 flex justify-center px-4">
|
||||
<div className="relative w-full max-w-3xl">
|
||||
{/* Blue glow */}
|
||||
<div
|
||||
@@ -447,7 +478,7 @@ export default function OraclePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ── Overlays ────────────────────────────────────────────────────────── */}
|
||||
<ShareModal
|
||||
|
||||
@@ -13,6 +13,9 @@ import type {
|
||||
ImportProposal,
|
||||
ImportReviewDecision,
|
||||
QdScoreEntry,
|
||||
OracleClientDataListItem,
|
||||
OracleClientDataDetail,
|
||||
OracleClientTimelineItem,
|
||||
} from '@/types/crmTypes';
|
||||
import { VELOCITY_TOKEN_KEY } from '@/lib/velocityPlatformClient';
|
||||
|
||||
@@ -218,3 +221,41 @@ export async function commitImportBatch(batchId: string): Promise<{
|
||||
}>(`/api/crm/imports/${batchId}/commit`, { method: 'POST' });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function fetchOracleClientData(params?: {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ items: OracleClientDataListItem[]; count: number }> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.search) qs.set('search', params.search);
|
||||
if (params?.limit != null) qs.set('limit', String(params.limit));
|
||||
if (params?.offset != null) qs.set('offset', String(params.offset));
|
||||
const res = await apiFetch<{ status: string; data: OracleClientDataListItem[]; meta?: { count?: number } }>(
|
||||
`/api/crm/client-data?${qs}`,
|
||||
);
|
||||
return { items: res.data, count: res.meta?.count ?? res.data.length };
|
||||
}
|
||||
|
||||
export async function fetchOracleClientDataDetail(personId: string): Promise<OracleClientDataDetail> {
|
||||
const res = await apiFetch<{ status: string; data: OracleClientDataDetail }>(`/api/crm/client-data/${personId}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function patchOracleClientData(
|
||||
personId: string,
|
||||
patch: Record<string, string | null>,
|
||||
): Promise<{ person_id: string; updated: string[] }> {
|
||||
const res = await apiFetch<{ status: string; data: { person_id: string; updated: string[] } }>(
|
||||
`/api/crm/client-data/${personId}`,
|
||||
{ method: 'PATCH', body: JSON.stringify(patch) },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function fetchOracleClientTimeline(personId: string): Promise<OracleClientTimelineItem[]> {
|
||||
const res = await apiFetch<{ status: string; data: OracleClientTimelineItem[] }>(
|
||||
`/api/crm/client-data/${personId}/timeline`,
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
392
app/src/oracle/components/ClientDataLens.tsx
Normal file
392
app/src/oracle/components/ClientDataLens.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Clock,
|
||||
Database,
|
||||
Mail,
|
||||
Phone,
|
||||
Search,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
fetchOracleClientData,
|
||||
fetchOracleClientDataDetail,
|
||||
patchOracleClientData,
|
||||
} from '@/lib/crmApi';
|
||||
import type { OracleClientDataDetail, OracleClientDataListItem } from '@/types/crmTypes';
|
||||
|
||||
const STAGES = [
|
||||
'new',
|
||||
'contacted',
|
||||
'qualified',
|
||||
'site_visit_scheduled',
|
||||
'site_visited',
|
||||
'negotiation',
|
||||
'booking_initiated',
|
||||
'booked',
|
||||
];
|
||||
|
||||
function fmt(value: unknown): string {
|
||||
if (value == null || value === '') return '-';
|
||||
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function shortDate(value: unknown): string {
|
||||
if (!value) return '-';
|
||||
const date = new Date(String(value));
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function FieldRow({ label, value }: { label: string; value: unknown }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[150px_1fr] gap-3 border-b border-white/[0.06] py-2.5 text-sm">
|
||||
<span className="text-zinc-500">{label}</span>
|
||||
<span className="min-w-0 break-words text-zinc-200">{fmt(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyDiagnostic({ error, loading }: { error: string | null; loading: boolean }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[420px] items-center justify-center p-8">
|
||||
<div className="max-w-xl rounded-3xl border border-amber-400/20 bg-amber-500/[0.08] p-6">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border border-amber-300/25 bg-amber-400/10">
|
||||
{loading ? <Database className="h-5 w-5 animate-pulse text-amber-200" /> : <AlertTriangle className="h-5 w-5 text-amber-200" />}
|
||||
</div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-amber-200">Client data unavailable</p>
|
||||
<h3 className="mt-2 text-xl font-semibold text-zinc-50">
|
||||
{loading ? 'Loading Velocity CRM data...' : 'The CRM lens has no rows to show.'}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-zinc-400">
|
||||
{error
|
||||
? error
|
||||
: 'The local frontend is alive, but the CRM API returned no clients. Start the local backend, start Docker/Postgres, apply the canonical schema, and seed synthetic_crm_v2 before verifying this tab.'}
|
||||
</p>
|
||||
<div className="mt-5 rounded-2xl border border-white/10 bg-black/25 p-4 text-xs leading-6 text-zinc-400">
|
||||
<p className="font-semibold text-zinc-200">Expected local stack</p>
|
||||
<p>Backend: http://127.0.0.1:8001</p>
|
||||
<p>DB: 127.0.0.1:54329 / velocity_local</p>
|
||||
<p>Dataset: db assets/synthetic_crm_v2/csv</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientDataLens() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [items, setItems] = useState<OracleClientDataListItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<OracleClientDataDetail | null>(null);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState({
|
||||
full_name: '',
|
||||
primary_email: '',
|
||||
primary_phone: '',
|
||||
buyer_type: '',
|
||||
communication_preference: '',
|
||||
best_contact_time: '',
|
||||
budget_band: '',
|
||||
urgency: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => {
|
||||
setLoadingList(true);
|
||||
setError(null);
|
||||
void fetchOracleClientData({ search: query, limit: 80 })
|
||||
.then(({ items: rows }) => {
|
||||
setItems(rows);
|
||||
setSelectedId((current) => current ?? rows[0]?.person_id ?? null);
|
||||
if (!rows.length) {
|
||||
setDetail(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setItems([]);
|
||||
setSelectedId(null);
|
||||
setDetail(null);
|
||||
setError(err instanceof Error ? err.message : 'Failed to reach the CRM client data API.');
|
||||
})
|
||||
.finally(() => setLoadingList(false));
|
||||
}, 180);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
setLoadingDetail(true);
|
||||
setError(null);
|
||||
void fetchOracleClientDataDetail(selectedId)
|
||||
.then((data) => {
|
||||
const profile = data.profile ?? {};
|
||||
setDetail(data);
|
||||
setDraft({
|
||||
full_name: String(profile.full_name ?? ''),
|
||||
primary_email: String(profile.primary_email ?? ''),
|
||||
primary_phone: String(profile.primary_phone ?? ''),
|
||||
buyer_type: String(profile.buyer_type ?? ''),
|
||||
communication_preference: String(profile.communication_preference ?? ''),
|
||||
best_contact_time: String(profile.best_contact_time ?? ''),
|
||||
budget_band: String(profile.budget_band ?? ''),
|
||||
urgency: String(profile.urgency ?? ''),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
setDetail(null);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load the selected client record.');
|
||||
})
|
||||
.finally(() => setLoadingDetail(false));
|
||||
}, [selectedId]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => items.find((item) => item.person_id === selectedId) ?? items[0] ?? null,
|
||||
[items, selectedId],
|
||||
);
|
||||
const profile = detail?.profile ?? {};
|
||||
const currentStage = String(profile.lead_status ?? selected?.lead_status ?? 'new');
|
||||
const qdScore = Number(selected?.qd_score ?? 0);
|
||||
const activeStageIndex = Math.max(0, STAGES.indexOf(currentStage));
|
||||
|
||||
async function saveDraft() {
|
||||
if (!selectedId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await patchOracleClientData(selectedId, draft);
|
||||
const fresh = await fetchOracleClientDataDetail(selectedId);
|
||||
setDetail(fresh);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save client data.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative z-10 grid h-full min-h-0 grid-cols-[360px_minmax(0,1fr)] gap-4 overflow-hidden px-5 pb-5 pt-3">
|
||||
<aside className="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-cyan-400/15 bg-[linear-gradient(180deg,rgba(8,47,73,0.16),rgba(0,0,0,0.22))]">
|
||||
<div className="border-b border-white/10 p-5">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-cyan-300">Client Data</p>
|
||||
<h2 className="mt-1 text-xl font-semibold text-zinc-50">Velocity CRM Lens</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">Search, inspect, edit, and follow the client signal trail.</p>
|
||||
</div>
|
||||
<Sparkles className="h-5 w-5 text-cyan-200" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-zinc-500" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search name, phone, email, project..."
|
||||
className="h-10 border-white/10 bg-black/25 pl-9 text-sm text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.person_id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(item.person_id)}
|
||||
className={`mb-2 w-full rounded-2xl border p-3 text-left transition ${
|
||||
item.person_id === selectedId
|
||||
? 'border-cyan-300/40 bg-cyan-500/12 shadow-[0_0_24px_rgba(34,211,238,0.08)]'
|
||||
: 'border-white/8 bg-white/[0.03] hover:bg-white/[0.06]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl border border-blue-400/20 bg-blue-500/15 text-sm font-semibold text-blue-100">
|
||||
{item.full_name.slice(0, 1)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-zinc-100">{item.full_name}</p>
|
||||
<p className="truncate text-xs text-zinc-500">{item.projects || item.buyer_type || 'No project interest'}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-zinc-400">
|
||||
<span className="rounded-full bg-cyan-500/10 px-2 py-0.5 text-cyan-300">QD {Math.round((item.qd_score || 0) * 100)}</span>
|
||||
<span>{item.days_since_contact == null ? 'No contact clock' : `${item.days_since_contact}d since contact`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{!items.length && (
|
||||
<div className="px-4 py-7 text-sm text-zinc-500">
|
||||
{loadingList ? 'Loading clients...' : 'No clients found.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 overflow-hidden rounded-3xl border border-white/10 bg-black/20">
|
||||
{!selected ? (
|
||||
<EmptyDiagnostic error={error} loading={loadingList} />
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<section className="border-b border-white/10 bg-[linear-gradient(135deg,rgba(14,116,144,0.14),rgba(15,23,42,0.35))] p-5">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-2xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-zinc-500">Contact Record</p>
|
||||
<h1 className="mt-1 text-3xl font-semibold text-zinc-50">{selected.full_name}</h1>
|
||||
<div className="mt-3 flex flex-wrap gap-4 text-sm text-zinc-400">
|
||||
<span className="flex items-center gap-1.5"><Phone className="h-4 w-4" />{fmt(selected.primary_phone)}</span>
|
||||
<span className="flex items-center gap-1.5"><Mail className="h-4 w-4" />{fmt(selected.primary_email)}</span>
|
||||
<span className="flex items-center gap-1.5"><UserRound className="h-4 w-4" />{fmt(selected.broker_name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-2xl border border-cyan-400/20 bg-cyan-500/10 px-4 py-3 text-right">
|
||||
<p className="text-xs text-cyan-300">QD Signal</p>
|
||||
<p className="text-3xl font-semibold text-cyan-100">{Math.round(qdScore * 100)}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3">
|
||||
<p className="text-xs text-zinc-500">Last Contact</p>
|
||||
<p className="mt-1 max-w-[160px] truncate text-sm font-semibold text-zinc-100">{shortDate(selected.last_contact_at)}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3">
|
||||
<p className="text-xs text-zinc-500">Next Action</p>
|
||||
<p className="mt-1 max-w-[180px] truncate text-sm font-semibold text-zinc-100">{fmt(selected.next_best_action)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-8 overflow-hidden rounded-full border border-white/10 bg-white/[0.04]">
|
||||
{STAGES.map((stage, index) => {
|
||||
const active = activeStageIndex >= index;
|
||||
return (
|
||||
<div key={stage} className={`py-2 text-center text-[11px] font-semibold ${active ? 'bg-blue-500/70 text-white' : 'text-zinc-500'}`}>
|
||||
{stage.replace(/_/g, ' ')}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="min-h-0 flex-1 overflow-y-auto p-5">
|
||||
{loadingDetail && <p className="mb-4 text-sm text-zinc-500">Loading record...</p>}
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_410px]">
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-100">Editable Details</h3>
|
||||
<p className="text-xs text-zinc-500">Typed API writes only. Oracle SQL remains read-only.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveDraft()}
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-blue-500 px-4 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
<Check className="mr-1 inline h-3.5 w-3.5" />
|
||||
{saving ? 'Saving...' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{Object.entries(draft).map(([key, value]) => (
|
||||
<label key={key} className="text-xs text-zinc-500">
|
||||
{key.replace(/_/g, ' ')}
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, [key]: event.target.value }))}
|
||||
className="mt-1 h-9 border-white/10 bg-black/25 text-sm text-zinc-100"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<h3 className="mb-4 text-base font-semibold text-zinc-100">Property Interests</h3>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{(detail?.property_interests ?? []).slice(0, 8).map((interest, index) => (
|
||||
<div key={String(interest.interest_id ?? index)} className="rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||
<p className="font-medium text-zinc-100">{fmt(interest.project_name)}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">{fmt(interest.configuration)} | {fmt(interest.budget_min)}-{fmt(interest.budget_max)}</p>
|
||||
<p className="mt-2 text-xs text-cyan-300">Priority {fmt(interest.priority)}</p>
|
||||
</div>
|
||||
))}
|
||||
{!(detail?.property_interests ?? []).length && <p className="text-sm text-zinc-500">No property interests loaded for this client.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<h3 className="mb-4 text-base font-semibold text-zinc-100">Unified Engagement Timeline</h3>
|
||||
<div className="space-y-3">
|
||||
{(detail?.timeline ?? []).slice(0, 24).map((event) => (
|
||||
<div key={event.id} className="flex gap-3 rounded-2xl border border-white/8 bg-black/25 p-3">
|
||||
<Activity className="mt-1 h-4 w-4 text-blue-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-zinc-100">{fmt(event.title || event.type)}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">{fmt(event.summary)}</p>
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-[11px] text-zinc-600">{shortDate(event.date)}</span>
|
||||
</div>
|
||||
))}
|
||||
{!(detail?.timeline ?? []).length && <p className="text-sm text-zinc-500">No timeline events loaded. Seed v2 interactions/messages/calls/visits to populate this rail.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-5">
|
||||
<div className="rounded-3xl border border-blue-400/20 bg-blue-500/10 p-5">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-blue-300">Next Best Action</p>
|
||||
<p className="mt-2 text-lg font-semibold text-zinc-100">{fmt(selected.next_best_action ?? profile.recommended_action)}</p>
|
||||
<p className="mt-3 text-sm leading-6 text-zinc-400">{fmt(profile.rationale)}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-base font-semibold text-zinc-100"><Clock className="h-4 w-4" /> Engagement Intelligence</h3>
|
||||
<FieldRow label="Last Contact" value={shortDate(selected.last_contact_at)} />
|
||||
<FieldRow label="Channel" value={selected.last_channel} />
|
||||
<FieldRow label="Days Since" value={selected.days_since_contact} />
|
||||
<FieldRow label="Preference" value={selected.communication_preference} />
|
||||
<FieldRow label="Best Time" value={profile.best_contact_time} />
|
||||
</div>
|
||||
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-base font-semibold text-zinc-100"><Zap className="h-4 w-4 text-cyan-300" /> Extracted Facts</h3>
|
||||
{(detail?.extracted_facts ?? []).slice(0, 10).map((fact, index) => (
|
||||
<div key={String(fact.fact_id ?? index)} className="mb-2 rounded-2xl bg-black/25 p-3 text-xs">
|
||||
<p className="font-semibold text-cyan-200">{fmt(fact.fact_type)}</p>
|
||||
<p className="mt-1 text-zinc-400">{fmt(fact.fact_value)}</p>
|
||||
</div>
|
||||
))}
|
||||
{!(detail?.extracted_facts ?? []).length && <p className="text-sm text-zinc-500">No extracted facts loaded.</p>}
|
||||
</div>
|
||||
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<h3 className="mb-3 text-base font-semibold text-zinc-100">Open Opportunities</h3>
|
||||
{(detail?.opportunities ?? []).slice(0, 8).map((opp, index) => (
|
||||
<div key={String(opp.opportunity_id ?? index)} className="mb-2 flex items-center justify-between rounded-2xl bg-black/25 p-3 text-xs">
|
||||
<span className="text-zinc-200">{fmt(opp.project_name ?? opp.stage)}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-zinc-500" />
|
||||
</div>
|
||||
))}
|
||||
{!(detail?.opportunities ?? []).length && <p className="text-sm text-zinc-500">No opportunities loaded.</p>}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -215,3 +215,44 @@ export interface KanbanCard {
|
||||
urgency: string | null;
|
||||
intent_score: number;
|
||||
}
|
||||
|
||||
export interface OracleClientDataListItem {
|
||||
person_id: string;
|
||||
full_name: string;
|
||||
primary_email: string | null;
|
||||
primary_phone: string | null;
|
||||
buyer_type: string | null;
|
||||
broker_name: string | null;
|
||||
communication_preference: string | null;
|
||||
best_contact_time: string | null;
|
||||
lead_id: string | null;
|
||||
lead_status: string | null;
|
||||
budget_band: string | null;
|
||||
urgency: string | null;
|
||||
qd_score: number;
|
||||
last_contact_at: string | null;
|
||||
last_channel: string | null;
|
||||
days_since_contact: number | null;
|
||||
next_best_action: string | null;
|
||||
next_action_priority: string | null;
|
||||
projects: string;
|
||||
interest_count: number;
|
||||
}
|
||||
|
||||
export interface OracleClientTimelineItem {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string | null;
|
||||
summary: string | null;
|
||||
date: string | null;
|
||||
actor: string | null;
|
||||
}
|
||||
|
||||
export interface OracleClientDataDetail {
|
||||
profile: Record<string, unknown>;
|
||||
property_interests: Array<Record<string, unknown>>;
|
||||
opportunities: Array<Record<string, unknown>>;
|
||||
extracted_facts: Array<Record<string, unknown>>;
|
||||
qd_scores: Array<Record<string, unknown>>;
|
||||
timeline: OracleClientTimelineItem[];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
import { inspectAttr } from 'kimi-plugin-inspect-react'
|
||||
|
||||
const backendProxyTarget = process.env.VITE_BACKEND_PROXY_TARGET?.trim() || "https://velocity.desineuron.in"
|
||||
const backendProxyTarget = process.env.VITE_BACKEND_PROXY_TARGET?.trim() || "http://127.0.0.1:8001"
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
|
||||
Reference in New Issue
Block a user