Initial commit: Velocity-OS migration
This commit is contained in:
32
webos/src/pillars/pipeline/client360/Client360.module.css
Normal file
32
webos/src/pillars/pipeline/client360/Client360.module.css
Normal file
@@ -0,0 +1,32 @@
|
||||
/* Client360 */
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
|
||||
.backBtn { align-self: flex-start; background: none; border: none; color: var(--color-text-tertiary); font-size: var(--text-sm); cursor: pointer; padding: var(--space-4) var(--space-6) 0; font-family: var(--font-sans); transition: color var(--duration-fast) var(--ease-standard); }
|
||||
.backBtn:hover { color: var(--color-text-primary); }
|
||||
/* Header */
|
||||
.header { margin: var(--space-3) var(--space-6) 0; padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-5); }
|
||||
.headerTop { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
|
||||
.identity { display: flex; align-items: center; gap: var(--space-4); }
|
||||
.avatar { width: 52px; height: 52px; border-radius: var(--radius-full); background: var(--color-violet-glow); border: 2px solid var(--color-violet); display: flex; align-items: center; justify-content: center; font-size: var(--text-xl); font-weight: var(--font-bold); color: var(--color-violet-light); flex-shrink: 0; overflow: hidden; }
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.nameBlock { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.name { font-size: var(--text-2xl); font-weight: var(--font-bold); color: var(--color-text-primary); letter-spacing: var(--tracking-tight); margin: 0; }
|
||||
.meta { font-size: var(--text-sm); color: var(--color-text-secondary); margin: 0; }
|
||||
.intentBadge { flex-shrink: 0; align-self: flex-start; }
|
||||
/* KPI row */
|
||||
.kpiRow { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-3); }
|
||||
.kpiChip { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-4); }
|
||||
.kpiLabel { font-size: 9px; font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); }
|
||||
.qdWrap { display: flex; align-items: center; gap: var(--space-3); }
|
||||
.qdValue { font-size: var(--text-2xl); font-weight: var(--font-bold); }
|
||||
.qdDelta { font-size: 10px; display: block; }
|
||||
.kpiValue { font-size: var(--text-lg); font-weight: var(--font-semibold); color: var(--color-text-primary); }
|
||||
.kpiSub { font-size: var(--text-xs); color: var(--color-text-tertiary); }
|
||||
/* Action bar */
|
||||
.actionBar { display: flex; gap: var(--space-3); flex-wrap: wrap; }
|
||||
/* Tabs */
|
||||
.tabNav { display: flex; gap: 0; border-bottom: var(--glass-border); margin: var(--space-4) var(--space-6) 0; }
|
||||
.tabBtn { position: relative; background: none; border: none; padding: var(--space-3) var(--space-5); font-family: var(--font-sans); font-size: var(--text-sm); font-weight: var(--font-medium); color: var(--color-text-tertiary); cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); }
|
||||
.tabActive { color: var(--color-text-primary); }
|
||||
.tabUnderline { position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: var(--color-violet); border-radius: 1px; }
|
||||
/* Content */
|
||||
.tabContent { padding: var(--space-5) var(--space-6) var(--space-10); flex: 1; }
|
||||
217
webos/src/pillars/pipeline/client360/Client360.tsx
Normal file
217
webos/src/pillars/pipeline/client360/Client360.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { QDRing } from './QDRing';
|
||||
import { ConversationsTab } from './tabs/Conversations';
|
||||
import { IntelligenceTab } from './tabs/Intelligence';
|
||||
import { PropertiesTab } from './tabs/Properties';
|
||||
import { TasksTab } from './tabs/Tasks';
|
||||
import { useClient360 } from '../../../shared/hooks/useClient360';
|
||||
import styles from './Client360.module.css';
|
||||
|
||||
/**
|
||||
* Client360
|
||||
* The unified client entity. The most important screen in Velocity-OS.
|
||||
* Accessible at /pipeline/:personId — slides in with depth choreography.
|
||||
*
|
||||
* Four tabs only: Conversations | Intelligence | Properties | Tasks
|
||||
* Header: glassmorphic, always visible with QD ring, pipeline stage, last contact.
|
||||
*/
|
||||
|
||||
type Tab = 'conversations' | 'intelligence' | 'properties' | 'tasks';
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'conversations', label: 'Conversations' },
|
||||
{ id: 'intelligence', label: 'Intelligence' },
|
||||
{ id: 'properties', label: 'Properties' },
|
||||
{ id: 'tasks', label: 'Tasks' },
|
||||
];
|
||||
|
||||
export default function Client360() {
|
||||
const { personId } = useParams<{ personId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('conversations');
|
||||
|
||||
const { client, isLoading, error } = useClient360(personId!);
|
||||
|
||||
if (isLoading) return <Client360Skeleton />;
|
||||
if (error || !client) return <Client360Error onBack={() => navigate('/pipeline')} />;
|
||||
|
||||
const qdColor =
|
||||
client.qdScore >= 70 ? 'var(--color-green)' :
|
||||
client.qdScore >= 40 ? 'var(--color-amber)' :
|
||||
'var(--color-red)';
|
||||
|
||||
const intentLabel =
|
||||
client.qdScore >= 70 ? 'HIGH INTENT' :
|
||||
client.qdScore >= 40 ? 'MODERATE' :
|
||||
'LOW INTENT';
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{/* Back button */}
|
||||
<button
|
||||
className={styles.backBtn}
|
||||
onClick={() => navigate('/pipeline')}
|
||||
aria-label="Back to Pipeline"
|
||||
>
|
||||
← Pipeline
|
||||
</button>
|
||||
|
||||
{/* ── Glassmorphic Header ─────────────────────────────── */}
|
||||
<div className={`${styles.header} glass-heavy`}>
|
||||
<div className={styles.headerTop}>
|
||||
{/* Avatar + Identity */}
|
||||
<div className={styles.identity}>
|
||||
<div className={styles.avatar}>
|
||||
{client.avatarUrl
|
||||
? <img src={client.avatarUrl} alt={client.name} />
|
||||
: <span>{client.name.slice(0, 1)}</span>
|
||||
}
|
||||
</div>
|
||||
<div className={styles.nameBlock}>
|
||||
<h1 className={styles.name}>{client.name}</h1>
|
||||
<p className={styles.meta}>
|
||||
{client.location}
|
||||
{client.primaryPhone && ` · ${client.primaryPhone}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intent badge */}
|
||||
<span className={`badge badge-high-intent ${styles.intentBadge}`}
|
||||
style={{ borderColor: qdColor, color: qdColor }}>
|
||||
<span className="live-dot" style={{ background: qdColor }} />
|
||||
{intentLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Three KPI chips ──────────────────────────────── */}
|
||||
<div className={styles.kpiRow}>
|
||||
{/* QD Score chip */}
|
||||
<div className={`${styles.kpiChip} glass`}>
|
||||
<span className={styles.kpiLabel}>QD SCORE</span>
|
||||
<div className={styles.qdWrap}>
|
||||
<QDRing score={client.qdScore} size={48} color={qdColor} />
|
||||
<div>
|
||||
<span className={styles.qdValue}
|
||||
style={{ color: qdColor }}>{client.qdScore}</span>
|
||||
{client.qdDelta !== 0 && (
|
||||
<span className={styles.qdDelta}
|
||||
style={{ color: client.qdDelta > 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
|
||||
{client.qdDelta > 0 ? '+' : ''}{client.qdDelta} this week
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Stage chip */}
|
||||
<div className={`${styles.kpiChip} glass`}>
|
||||
<span className={styles.kpiLabel}>PIPELINE STAGE</span>
|
||||
<span className={styles.kpiValue}>
|
||||
{client.stageEmoji} {client.stageName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Last Contact chip */}
|
||||
<div className={`${styles.kpiChip} glass`}>
|
||||
<span className={styles.kpiLabel}>LAST CONTACT</span>
|
||||
<span className={styles.kpiValue}>{client.lastContactRelative}</span>
|
||||
<span className={styles.kpiSub}>via {client.lastContactChannel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Primary action bar ───────────────────────────── */}
|
||||
<div className={styles.actionBar}>
|
||||
<ActionButton
|
||||
icon="💬"
|
||||
label={client.lastContactChannel === 'WhatsApp' ? 'WhatsApp' : 'Message'}
|
||||
onClick={() => {/* open inline compose */}}
|
||||
primary
|
||||
/>
|
||||
<ActionButton icon="📞" label="Call" onClick={() => window.open(`tel:${client.primaryPhone}`)} />
|
||||
<ActionButton icon="📅" label="Schedule" onClick={() => {/* open task modal */}} />
|
||||
<ActionButton icon="⋯" label="More" onClick={() => {/* slide-up sheet */}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tab Navigation ──────────────────────────────────── */}
|
||||
<div className={styles.tabNav} role="tablist">
|
||||
{TABS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
role="tab"
|
||||
aria-selected={activeTab === id}
|
||||
className={`${styles.tabBtn} ${activeTab === id ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab(id)}
|
||||
>
|
||||
{label}
|
||||
{activeTab === id && (
|
||||
<motion.div
|
||||
layoutId="tab-underline"
|
||||
className={styles.tabUnderline}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 40 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Tab Content ─────────────────────────────────────── */}
|
||||
<div className={styles.tabContent}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
{activeTab === 'conversations' && <ConversationsTab personId={personId!} />}
|
||||
{activeTab === 'intelligence' && <IntelligenceTab client={client} />}
|
||||
{activeTab === 'properties' && <PropertiesTab personId={personId!} />}
|
||||
{activeTab === 'tasks' && <TasksTab personId={personId!} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-components ───────────────────────────────────────────
|
||||
function ActionButton({
|
||||
icon, label, onClick, primary = false,
|
||||
}: { icon: string; label: string; onClick: () => void; primary?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
className={primary ? 'btn-primary' : 'btn-ghost'}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
>
|
||||
<span>{icon}</span> {label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Client360Skeleton() {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={`${styles.header} glass-heavy shimmer`} style={{ minHeight: 220 }} />
|
||||
<div style={{ padding: 'var(--space-6)', display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
{[1,2,3].map(i => (
|
||||
<div key={i} className="glass shimmer" style={{ height: 80, borderRadius: 'var(--radius-lg)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Client360Error({ onBack }: { onBack: () => void }) {
|
||||
return (
|
||||
<div className={styles.root} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 'var(--space-4)', padding: 'var(--space-16)' }}>
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>Could not load client profile.</p>
|
||||
<button className="btn-ghost" onClick={onBack}>← Back to Pipeline</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
webos/src/pillars/pipeline/client360/QDRing.tsx
Normal file
92
webos/src/pillars/pipeline/client360/QDRing.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* QDRing
|
||||
* Animated SVG arc representing the Qualification Desire score (0–100).
|
||||
* Color: green (≥70), amber (40–69), red (<40).
|
||||
* Animates from 0 to score on mount; smooth spring on value change.
|
||||
*/
|
||||
interface QDRingProps {
|
||||
score: number;
|
||||
size?: number; // diameter in px (default 64)
|
||||
strokeWidth?: number;
|
||||
color?: string; // overrides semantic color if provided
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export function QDRing({
|
||||
score,
|
||||
size = 64,
|
||||
strokeWidth = 4,
|
||||
color,
|
||||
showLabel = false,
|
||||
}: QDRingProps) {
|
||||
const clampedScore = Math.max(0, Math.min(100, score));
|
||||
|
||||
const r = (size - strokeWidth) / 2;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
|
||||
// Arc: 0% = full stroke-dashoffset (hidden), 100% = 0 offset (full)
|
||||
const offset = circumference * (1 - clampedScore / 100);
|
||||
|
||||
// Semantic color
|
||||
const arcColor = color ?? (
|
||||
clampedScore >= 70 ? 'var(--color-green)' :
|
||||
clampedScore >= 40 ? 'var(--color-amber)' :
|
||||
'var(--color-red)'
|
||||
);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
role="img"
|
||||
aria-label={`QD Score: ${clampedScore}`}
|
||||
style={{ transform: 'rotate(-90deg)' }} // Start arc at top
|
||||
>
|
||||
{/* Track */}
|
||||
<circle
|
||||
cx={cx} cy={cy} r={r}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
|
||||
{/* Animated arc */}
|
||||
<motion.circle
|
||||
cx={cx} cy={cy} r={r}
|
||||
fill="none"
|
||||
stroke={arcColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
initial={{ strokeDashoffset: circumference }}
|
||||
animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 0.8, ease: [0.4, 0, 0.2, 1] }}
|
||||
style={{
|
||||
filter: `drop-shadow(0 0 4px ${arcColor})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Optional center label (shown rotated back) */}
|
||||
{showLabel && (
|
||||
<text
|
||||
x={cx} y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={arcColor}
|
||||
fontSize={size * 0.22}
|
||||
fontWeight="700"
|
||||
fontFamily="var(--font-sans)"
|
||||
style={{ transform: `rotate(90deg)`, transformOrigin: `${cx}px ${cy}px` }}
|
||||
>
|
||||
{clampedScore}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Conversations tab */
|
||||
.root { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.skeleton { height: 100px; border-radius: var(--radius-lg); }
|
||||
.eventCard { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.eventHeader { display: flex; align-items: center; justify-content: space-between; }
|
||||
.channelBadge { font-size: var(--text-xs); font-weight: var(--font-semibold); color: var(--color-text-secondary); }
|
||||
.timestamp { font-size: var(--text-xs); color: var(--color-text-tertiary); }
|
||||
/* Thread */
|
||||
.thread { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.bubble { display: flex; align-items: flex-end; gap: var(--space-2); max-width: 80%; }
|
||||
.inbound { align-self: flex-start; }
|
||||
.outbound { align-self: flex-end; flex-direction: row-reverse; }
|
||||
.bubbleText { background: var(--glass-bg); border-radius: var(--radius-lg); padding: var(--space-2) var(--space-3); font-size: var(--text-sm); color: var(--color-text-primary); }
|
||||
.outbound .bubbleText { background: var(--color-violet); color: white; }
|
||||
.status { font-size: 10px; color: var(--color-text-tertiary); }
|
||||
.threadActions { display: flex; gap: var(--space-2); margin-top: var(--space-1); }
|
||||
/* Reply */
|
||||
.replyBox { display: flex; gap: var(--space-2); align-items: center; margin-top: var(--space-2); }
|
||||
.replyInput { flex: 1; background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-lg); padding: var(--space-2) var(--space-3); font-family: var(--font-sans); font-size: var(--text-sm); color: var(--color-text-primary); outline: none; }
|
||||
/* Call */
|
||||
.callRecord { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.callMeta { display: flex; gap: var(--space-3); font-size: var(--text-xs); color: var(--color-text-secondary); }
|
||||
.keyMoments { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
|
||||
.kmLabel { font-size: var(--text-xs); color: var(--color-text-tertiary); }
|
||||
.kmChip { font-size: var(--text-xs); background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-full); padding: 2px var(--space-2); color: var(--color-text-secondary); }
|
||||
.callActions { display: flex; gap: var(--space-2); }
|
||||
147
webos/src/pillars/pipeline/client360/tabs/Conversations.tsx
Normal file
147
webos/src/pillars/pipeline/client360/tabs/Conversations.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useConversations } from '../../../shared/hooks/useClient360';
|
||||
import styles from './Conversations.module.css';
|
||||
|
||||
/**
|
||||
* Conversations Tab
|
||||
* Unified chronological feed: WhatsApp threads + call records + emails.
|
||||
* All in one scroll — no navigation to separate Comms module ever needed.
|
||||
* WhatsApp threads render inline with reply. Calls show key moments.
|
||||
*/
|
||||
|
||||
interface ConversationsTabProps {
|
||||
personId: string;
|
||||
}
|
||||
|
||||
type EventType = 'whatsapp' | 'call' | 'email';
|
||||
|
||||
interface ConvEvent {
|
||||
id: string;
|
||||
type: EventType;
|
||||
timestamp: string;
|
||||
timestampRelative: string;
|
||||
// WhatsApp
|
||||
messages?: { sender: 'client' | 'you'; text: string; status?: '✓' | '✓✓' }[];
|
||||
// Call
|
||||
duration?: string;
|
||||
direction?: 'inbound' | 'outbound';
|
||||
keyMoments?: string[];
|
||||
hasTranscript?: boolean;
|
||||
// Email
|
||||
subject?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export function ConversationsTab({ personId }: ConversationsTabProps) {
|
||||
const { events, isLoading, sendWhatsApp } = useConversations(personId);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [replyingToId, setReplyingToId] = useState<string | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className={`${styles.skeleton} shimmer`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{events.map((event, i) => (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
className={`${styles.eventCard} glass-card`}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.06, duration: 0.25 }}
|
||||
>
|
||||
{/* Event header */}
|
||||
<div className={styles.eventHeader}>
|
||||
<span className={styles.channelBadge}
|
||||
data-type={event.type}>
|
||||
{event.type === 'whatsapp' ? '💬 WhatsApp'
|
||||
: event.type === 'call' ? '📞 Call'
|
||||
: '✉️ Email'}
|
||||
</span>
|
||||
<span className={styles.timestamp}>{event.timestampRelative}</span>
|
||||
</div>
|
||||
|
||||
{/* WhatsApp thread */}
|
||||
{event.type === 'whatsapp' && event.messages && (
|
||||
<div className={styles.thread}>
|
||||
{event.messages.map((msg, mi) => (
|
||||
<div
|
||||
key={mi}
|
||||
className={`${styles.bubble} ${msg.sender === 'you' ? styles.outbound : styles.inbound}`}
|
||||
>
|
||||
<span className={styles.bubbleText}>{msg.text}</span>
|
||||
{msg.sender === 'you' && msg.status && (
|
||||
<span className={styles.status}>{msg.status}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Inline reply */}
|
||||
{replyingToId === event.id ? (
|
||||
<div className={styles.replyBox}>
|
||||
<input
|
||||
value={replyText}
|
||||
onChange={e => setReplyText(e.target.value)}
|
||||
placeholder="Type a reply…"
|
||||
className={styles.replyInput}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && replyText.trim()) {
|
||||
sendWhatsApp(replyText);
|
||||
setReplyText('');
|
||||
setReplyingToId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="btn-primary" onClick={() => {
|
||||
if (replyText.trim()) { sendWhatsApp(replyText); setReplyText(''); setReplyingToId(null); }
|
||||
}}>Send</button>
|
||||
<button className="btn-ghost" onClick={() => setReplyingToId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.threadActions}>
|
||||
<button className="btn-ghost" onClick={() => setReplyingToId(event.id)}>
|
||||
Reply in WhatsApp
|
||||
</button>
|
||||
<button className="btn-ghost">AI ✦ Summarize</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Call record */}
|
||||
{event.type === 'call' && (
|
||||
<div className={styles.callRecord}>
|
||||
<div className={styles.callMeta}>
|
||||
<span>{event.direction === 'inbound' ? '↙ Inbound' : '↗ Outbound'}</span>
|
||||
{event.duration && <span>· {event.duration}</span>}
|
||||
{event.hasTranscript && <span>· Transcript available</span>}
|
||||
</div>
|
||||
{event.keyMoments && event.keyMoments.length > 0 && (
|
||||
<div className={styles.keyMoments}>
|
||||
<span className={styles.kmLabel}>Key moments:</span>
|
||||
{event.keyMoments.map((m, mi) => (
|
||||
<span key={mi} className={styles.kmChip}>{m}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{event.hasTranscript && (
|
||||
<div className={styles.callActions}>
|
||||
<button className="btn-ghost">Read Transcript</button>
|
||||
<button className="btn-ghost">View Extracted Facts</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* Intelligence tab */
|
||||
.root { display: flex; flex-direction: column; gap: var(--space-5); }
|
||||
.insightCard { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.insightHeader { display: flex; align-items: center; justify-content: space-between; }
|
||||
.aiStar { font-size: var(--text-xs); font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); color: var(--color-violet-light); }
|
||||
.insightText { font-size: var(--text-base); color: var(--color-text-primary); line-height: var(--leading-relaxed); margin: 0; }
|
||||
.section { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.sectionTitle { font-size: 10px; font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); }
|
||||
.chipsGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--space-2); }
|
||||
.factChip { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-3); border-radius: var(--radius-lg); }
|
||||
.chipLabel { font-size: 10px; color: var(--color-text-tertiary); font-weight: var(--font-medium); }
|
||||
.chipValue { font-size: var(--text-sm); color: var(--color-text-primary); font-weight: var(--font-semibold); }
|
||||
.objectionList { display: flex; flex-direction: column; gap: var(--space-2); list-style: none; padding: 0; margin: 0; }
|
||||
.objectionItem { font-size: var(--text-sm); color: var(--color-text-secondary); }
|
||||
.biometricSection { }
|
||||
.sparkline { width: 100%; height: 64px; }
|
||||
.peakLabel { font-size: var(--text-xs); color: var(--color-text-tertiary); margin: var(--space-1) 0 0; }
|
||||
150
webos/src/pillars/pipeline/client360/tabs/Intelligence.tsx
Normal file
150
webos/src/pillars/pipeline/client360/tabs/Intelligence.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { QDRing } from '../QDRing';
|
||||
import styles from './Intelligence.module.css';
|
||||
|
||||
/**
|
||||
* Intelligence Tab
|
||||
* Biometric data expressed as business meaning — never raw scores.
|
||||
* Sections: AI Insight (act-able) | Extracted Facts chips | Objections | Biometric sparkline
|
||||
*/
|
||||
|
||||
interface IntelligenceTabProps {
|
||||
client: {
|
||||
id: string;
|
||||
aiInsight?: string;
|
||||
extractedFacts?: { budget?: string; timeline?: string; decisionMakers?: string; [key: string]: string | undefined };
|
||||
objections?: string[];
|
||||
qdHistory?: { date: string; score: number; label?: string }[];
|
||||
qdScore: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function IntelligenceTab({ client }: IntelligenceTabProps) {
|
||||
const facts = client.extractedFacts ?? {};
|
||||
const factChips = [
|
||||
{ label: 'Budget', value: facts.budget },
|
||||
{ label: 'Timeline', value: facts.timeline },
|
||||
{ label: 'Decision Makers', value: facts.decisionMakers },
|
||||
{ label: 'Property Type', value: facts.propertyType },
|
||||
{ label: 'Location Pref', value: facts.locationPreference },
|
||||
].filter(f => f.value);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{/* AI Insight — always first */}
|
||||
{client.aiInsight && (
|
||||
<motion.div
|
||||
className={`${styles.insightCard} glass-card glass-violet`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className={styles.insightHeader}>
|
||||
<span className={styles.aiStar}>✦ AI INSIGHT</span>
|
||||
<button className="btn-primary">Act</button>
|
||||
</div>
|
||||
<p className={styles.insightText}>{client.aiInsight}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Extracted Facts chips */}
|
||||
{factChips.length > 0 && (
|
||||
<motion.div
|
||||
className={styles.section}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.3 }}
|
||||
>
|
||||
<h3 className={styles.sectionTitle}>EXTRACTED FACTS</h3>
|
||||
<div className={styles.chipsGrid}>
|
||||
{factChips.map(({ label, value }, i) => (
|
||||
<motion.div
|
||||
key={label}
|
||||
className={`${styles.factChip} glass`}
|
||||
initial={{ opacity: 0, scale: 0.94 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.15 + i * 0.05 }}
|
||||
>
|
||||
<span className={styles.chipLabel}>{label}</span>
|
||||
<span className={styles.chipValue}>{value}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Objections */}
|
||||
{client.objections && client.objections.length > 0 && (
|
||||
<motion.div
|
||||
className={styles.section}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.3 }}
|
||||
>
|
||||
<h3 className={styles.sectionTitle}>OBJECTIONS RAISED</h3>
|
||||
<ul className={styles.objectionList}>
|
||||
{client.objections.map((obj, i) => (
|
||||
<li key={i} className={styles.objectionItem}>· {obj}</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Biometric Engagement sparkline */}
|
||||
{client.qdHistory && client.qdHistory.length > 0 && (
|
||||
<motion.div
|
||||
className={`${styles.section} ${styles.biometricSection} glass-card`}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.3 }}
|
||||
>
|
||||
<h3 className={styles.sectionTitle}>BIOMETRIC ENGAGEMENT</h3>
|
||||
<QDSparkline history={client.qdHistory} currentScore={client.qdScore} />
|
||||
{client.qdHistory.slice(-1)[0]?.label && (
|
||||
<p className={styles.peakLabel}>
|
||||
Peak: {client.qdHistory.slice(-1)[0].label}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── QD Sparkline (inline SVG, no charting library dependency) ─
|
||||
function QDSparkline({
|
||||
history,
|
||||
currentScore,
|
||||
}: { history: { date: string; score: number; label?: string }[]; currentScore: number }) {
|
||||
if (history.length < 2) return null;
|
||||
const scores = history.map(h => h.score);
|
||||
const min = Math.min(...scores);
|
||||
const max = Math.max(...scores);
|
||||
const range = max - min || 1;
|
||||
const W = 320, H = 64, PAD = 8;
|
||||
const step = (W - PAD * 2) / (scores.length - 1);
|
||||
|
||||
const points = scores.map((s, i) => ({
|
||||
x: PAD + i * step,
|
||||
y: H - PAD - ((s - min) / range) * (H - PAD * 2),
|
||||
}));
|
||||
|
||||
const pathD = points.map((p, i) =>
|
||||
(i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)
|
||||
).join(' ');
|
||||
|
||||
const qdColor = currentScore >= 70 ? 'var(--color-green)'
|
||||
: currentScore >= 40 ? 'var(--color-amber)'
|
||||
: 'var(--color-red)';
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className={styles.sparkline}>
|
||||
<path d={pathD} fill="none" stroke={qdColor} strokeWidth="2" strokeLinecap="round" />
|
||||
{/* Peak dot */}
|
||||
{points.map((p, i) => scores[i] === max && (
|
||||
<circle key={i} cx={p.x} cy={p.y} r="4"
|
||||
fill={qdColor} opacity="0.9" />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Properties tab */
|
||||
.root { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.skeleton { height: 120px; border-radius: var(--radius-lg); }
|
||||
.propertyCard { display: flex; flex-direction: column; gap: var(--space-4); overflow: hidden; }
|
||||
.thumbnail { position: relative; height: 140px; border-radius: var(--radius-lg); overflow: hidden; background: var(--glass-bg); }
|
||||
.thumbnail img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.thumbPlaceholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 40px; }
|
||||
.primaryBadge { position: absolute; top: var(--space-2); right: var(--space-2); }
|
||||
.details { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.unitName { font-size: var(--text-base); font-weight: var(--font-semibold); color: var(--color-text-primary); margin: 0; }
|
||||
.unitMeta { font-size: var(--text-sm); color: var(--color-text-secondary); margin: 0; }
|
||||
.engagementLabel { font-size: var(--text-xs); font-weight: var(--font-semibold); margin: 0; }
|
||||
.propActions { display: flex; gap: var(--space-2); flex-wrap: wrap; }
|
||||
.reimagineWrap { padding-top: var(--space-4); }
|
||||
.empty { font-size: var(--text-sm); color: var(--color-text-tertiary); }
|
||||
121
webos/src/pillars/pipeline/client360/tabs/Properties.tsx
Normal file
121
webos/src/pillars/pipeline/client360/tabs/Properties.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { ReimaginePanel } from '../../../studio/ReimaginePanel';
|
||||
import { useClientProperties } from '../../../../shared/hooks/useClient360';
|
||||
import styles from './Properties.module.css';
|
||||
|
||||
/**
|
||||
* Properties Tab
|
||||
* Shows property interests linked to this client.
|
||||
* "Stage It" triggers ReimaginePanel inline — no navigation to Studio/Catalyst.
|
||||
* "Share Brochure" generates Vault link — one tap, no modal.
|
||||
*/
|
||||
|
||||
interface PropertiesTabProps {
|
||||
personId: string;
|
||||
}
|
||||
|
||||
export function PropertiesTab({ personId }: PropertiesTabProps) {
|
||||
const navigate = useNavigate();
|
||||
const { properties, isLoading } = useClientProperties(personId);
|
||||
const [reimagining, setReimagining] = useState<string | null>(null); // propertyId
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{[0, 1].map(i => <div key={i} className={`${styles.skeleton} shimmer`} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{properties.map((prop, i) => (
|
||||
<motion.div
|
||||
key={prop.id}
|
||||
className={`${styles.propertyCard} glass-card`}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.08, duration: 0.3 }}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className={styles.thumbnail}>
|
||||
{prop.thumbnailUrl
|
||||
? <img src={prop.thumbnailUrl} alt={prop.unitName} />
|
||||
: <div className={styles.thumbPlaceholder}>🏙</div>
|
||||
}
|
||||
{prop.isPrimary && (
|
||||
<span className={`badge badge-high-intent ${styles.primaryBadge}`}>
|
||||
★ Primary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className={styles.details}>
|
||||
<h3 className={styles.unitName}>{prop.projectName} · {prop.unitName}</h3>
|
||||
<p className={styles.unitMeta}>
|
||||
{prop.config} · {prop.area} · {prop.price}
|
||||
</p>
|
||||
<p className={styles.engagementLabel}
|
||||
style={{ color: prop.engagementLevel === 'High' ? 'var(--color-green)' : 'var(--color-text-secondary)' }}>
|
||||
{prop.engagementLevel} Engagement
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={styles.propActions}>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => navigate(`/studio/${prop.id}`)}
|
||||
>
|
||||
View Unit
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => {/* generate vault link + copy */}}
|
||||
>
|
||||
Share Brochure
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => setReimagining(
|
||||
reimagining === prop.id ? null : prop.id
|
||||
)}
|
||||
>
|
||||
✨ Stage It
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline ReimaginePanel — no navigation to Studio */}
|
||||
<AnimatedCollapse open={reimagining === prop.id}>
|
||||
<div className={styles.reimagineWrap}>
|
||||
<ReimaginePanel
|
||||
propertyId={prop.id}
|
||||
roomImageUrl={prop.thumbnailUrl}
|
||||
onResultSaved={() => setReimagining(null)}
|
||||
/>
|
||||
</div>
|
||||
</AnimatedCollapse>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{properties.length === 0 && (
|
||||
<p className={styles.empty}>No property interests linked yet.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedCollapse({ open, children }: { open: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
style={{ overflow: 'hidden' }}
|
||||
animate={{ height: open ? 'auto' : 0, opacity: open ? 1 : 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
14
webos/src/pillars/pipeline/client360/tabs/Tasks.module.css
Normal file
14
webos/src/pillars/pipeline/client360/tabs/Tasks.module.css
Normal file
@@ -0,0 +1,14 @@
|
||||
/* Tasks tab */
|
||||
.root { display: flex; flex-direction: column; gap: var(--space-5); }
|
||||
.skeleton { height: 56px; border-radius: var(--radius-md); }
|
||||
.group { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.groupLabel { font-size: 10px; font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); padding-bottom: var(--space-1); border-bottom: var(--glass-border); }
|
||||
.taskRow { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; }
|
||||
.checkOpen { color: var(--color-text-tertiary); font-size: 14px; flex-shrink: 0; }
|
||||
.checkDone { color: var(--color-green); font-size: 14px; flex-shrink: 0; }
|
||||
.taskContent { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.taskLabel { font-size: var(--text-sm); color: var(--color-text-primary); }
|
||||
.taskDue { font-size: 10px; color: var(--color-text-tertiary); }
|
||||
.aiChip { font-size: 10px; background: rgba(124,58,237,0.15); color: var(--color-violet-light); border-radius: var(--radius-full); padding: 1px var(--space-2); display: inline-block; }
|
||||
.taskActions { display: flex; gap: var(--space-1); flex-shrink: 0; }
|
||||
.empty { font-size: var(--text-sm); color: var(--color-text-tertiary); }
|
||||
104
webos/src/pillars/pipeline/client360/tabs/Tasks.tsx
Normal file
104
webos/src/pillars/pipeline/client360/tabs/Tasks.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { useClientTasks } from '../../../../shared/hooks/useClient360';
|
||||
import styles from './Tasks.module.css';
|
||||
|
||||
/**
|
||||
* Tasks Tab
|
||||
* Timeline: TODAY → UPCOMING → COMPLETED
|
||||
* Tasks are AI-generated or manually created reminders.
|
||||
* "Done" and "Snooze" are the only actions — no task configuration UI.
|
||||
*/
|
||||
|
||||
interface TasksTabProps {
|
||||
personId: string;
|
||||
}
|
||||
|
||||
export function TasksTab({ personId }: TasksTabProps) {
|
||||
const { tasks, isLoading, markDone, snooze } = useClientTasks(personId);
|
||||
|
||||
const today = tasks.filter(t => t.group === 'today');
|
||||
const upcoming = tasks.filter(t => t.group === 'upcoming');
|
||||
const completed = tasks.filter(t => t.group === 'completed');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{[0, 1, 2].map(i => <div key={i} className={`${styles.skeleton} shimmer`} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{today.length > 0 && (
|
||||
<TaskGroup label="TODAY" tasks={today} onDone={markDone} onSnooze={snooze} />
|
||||
)}
|
||||
{upcoming.length > 0 && (
|
||||
<TaskGroup label="UPCOMING" tasks={upcoming} onDone={markDone} onSnooze={snooze} />
|
||||
)}
|
||||
{completed.length > 0 && (
|
||||
<TaskGroup label="COMPLETED" tasks={completed} dim />
|
||||
)}
|
||||
{tasks.length === 0 && (
|
||||
<p className={styles.empty}>No tasks yet — create one to stay on track.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
label: string;
|
||||
dueAt?: string;
|
||||
group: 'today' | 'upcoming' | 'completed';
|
||||
isAIGenerated?: boolean;
|
||||
}
|
||||
|
||||
function TaskGroup({
|
||||
label, tasks, dim = false, onDone, onSnooze,
|
||||
}: {
|
||||
label: string;
|
||||
tasks: Task[];
|
||||
dim?: boolean;
|
||||
onDone?: (id: string) => void;
|
||||
onSnooze?: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.group}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: dim ? 0.55 : 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<h3 className={styles.groupLabel}>{label}</h3>
|
||||
{tasks.map((task, i) => (
|
||||
<motion.div
|
||||
key={task.id}
|
||||
className={styles.taskRow}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
>
|
||||
<span className={dim ? styles.checkDone : styles.checkOpen}>
|
||||
{dim ? '✓' : '○'}
|
||||
</span>
|
||||
<div className={styles.taskContent}>
|
||||
<span className={styles.taskLabel}>{task.label}</span>
|
||||
{task.dueAt && (
|
||||
<span className={styles.taskDue}>{task.dueAt}</span>
|
||||
)}
|
||||
{task.isAIGenerated && (
|
||||
<span className={styles.aiChip}>✦ AI</span>
|
||||
)}
|
||||
</div>
|
||||
{!dim && onDone && onSnooze && (
|
||||
<div className={styles.taskActions}>
|
||||
<button className="btn-ghost" onClick={() => onDone(task.id)}>Done</button>
|
||||
<button className="btn-ghost" onClick={() => onSnooze(task.id)}>Snooze</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user