forked from sagnik/Velocity-OS
Initial commit: Velocity-OS migration
This commit is contained in:
@@ -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