Files
Project_Velocity/app/src/oracle/components/ClientDataLens.tsx

393 lines
19 KiB
TypeScript

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>
);
}