From ed3a8f0eeaf97696324ca0a39d644b5144fe5216 Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:41:22 +0000 Subject: [PATCH] feat: implement 8 outcome-driven UI flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: Action completion loop — type-specific follow-through panels for defer (date picker), send_email (litigation AI link), browser automation (queue), and negotiate (dispute link) actions in Recommendations. P0: Litigation-to-dispute bridge — dispute picker dropdown, save-to-dispute button that creates correspondence, URL param pre-population from dispute context. P1: Evidence Timeline page — unified case timeline combining facts, deadlines, disputes, and documents from ChittyEvidence with contradiction alerts tab. P1: Account drill-down — click-to-expand with transaction list, sync balances button, APR display, last synced timestamps. P1: Legal deadline CRUD — create form with validation, mark-complete button, toast notifications. P2: Task Board page — Kanban view (4 columns) and list view with filters, status transitions, priority badges, Notion deep links. P2: Queue history — Active/History tabs on ActionQueue with decision history list showing approvals, rejections, deferrals, and savings. P2: Revenue management — add/edit forms in RevenueSources with manual entry, inline editing, confidence/recurrence fields. Navigation: 2 new routes (/evidence, /tasks), sidebar entries for Evidence Timeline and Task Board, expanded mobile nav (8 items). Co-Authored-By: Claude Opus 4.6 --- ui/src/components/MobileNav.tsx | 4 +- ui/src/components/Sidebar.tsx | 4 +- ui/src/components/planner/RevenueSources.tsx | 173 +++++++++++++- ui/src/lib/api.ts | 81 +++++++ ui/src/main.tsx | 4 + ui/src/pages/Accounts.tsx | 144 ++++++++++-- ui/src/pages/ActionQueue.tsx | 175 ++++++++++++--- ui/src/pages/Evidence.tsx | 215 ++++++++++++++++++ ui/src/pages/Legal.tsx | 159 +++++++++++-- ui/src/pages/LitigationAssistant.tsx | 95 +++++++- ui/src/pages/Recommendations.tsx | 175 ++++++++++++--- ui/src/pages/Tasks.tsx | 224 +++++++++++++++++++ 12 files changed, 1327 insertions(+), 126 deletions(-) create mode 100644 ui/src/pages/Evidence.tsx create mode 100644 ui/src/pages/Tasks.tsx diff --git a/ui/src/components/MobileNav.tsx b/ui/src/components/MobileNav.tsx index abb0cfa..502d867 100644 --- a/ui/src/components/MobileNav.tsx +++ b/ui/src/components/MobileNav.tsx @@ -1,12 +1,14 @@ import { NavLink } from 'react-router-dom'; import { cn } from '../lib/utils'; -import { LayoutDashboard, Zap, Receipt, ShieldAlert, Wallet, Settings } from 'lucide-react'; +import { LayoutDashboard, Zap, Receipt, ShieldAlert, Wallet, Scale, ListChecks, Settings } from 'lucide-react'; const navItems = [ { path: '/', label: 'Home', icon: LayoutDashboard }, { path: '/queue', label: 'Queue', icon: Zap }, { path: '/bills', label: 'Bills', icon: Receipt }, { path: '/disputes', label: 'Disputes', icon: ShieldAlert }, + { path: '/legal', label: 'Legal', icon: Scale }, + { path: '/tasks', label: 'Tasks', icon: ListChecks }, { path: '/accounts', label: 'Accounts', icon: Wallet }, { path: '/settings', label: 'Settings', icon: Settings }, ]; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index a544c1a..ba57c0f 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ import { NavLink } from 'react-router-dom'; import { cn } from '../lib/utils'; import { LayoutDashboard, Zap, Receipt, ShieldAlert, Wallet, Scale, Gavel, - Lightbulb, TrendingUp, Upload, Settings, LogOut, + Lightbulb, TrendingUp, Upload, Settings, LogOut, BookOpen, ListChecks, } from 'lucide-react'; import { logout, getUser } from '../lib/auth'; @@ -31,6 +31,7 @@ const navGroups: NavGroup[] = [ { path: '/disputes', label: 'Active Disputes', icon: ShieldAlert }, { path: '/legal', label: 'Legal Deadlines', icon: Scale }, { path: '/litigation', label: 'Litigation AI', icon: Gavel }, + { path: '/evidence', label: 'Evidence Timeline', icon: BookOpen }, ], }, { @@ -38,6 +39,7 @@ const navGroups: NavGroup[] = [ items: [ { path: '/queue', label: 'Action Queue', icon: Zap }, { path: '/recommendations', label: 'AI Recs', icon: Lightbulb }, + { path: '/tasks', label: 'Task Board', icon: ListChecks }, ], }, { diff --git a/ui/src/components/planner/RevenueSources.tsx b/ui/src/components/planner/RevenueSources.tsx index 6186381..52838ae 100644 --- a/ui/src/components/planner/RevenueSources.tsx +++ b/ui/src/components/planner/RevenueSources.tsx @@ -3,12 +3,21 @@ import { api, type RevenueSource } from '../../lib/api'; import { formatCurrency } from '../../lib/utils'; import { Card } from '../ui/Card'; import { ActionButton } from '../ui/ActionButton'; +import { useToast } from '../../lib/toast'; +import { Plus, X, Pencil, Check } from 'lucide-react'; export function RevenueSources() { const [sources, setSources] = useState([]); const [summary, setSummary] = useState({ count: 0, total_monthly: 0, weighted_monthly: 0 }); const [discovering, setDiscovering] = useState(false); const [error, setError] = useState(null); + const [showAdd, setShowAdd] = useState(false); + const [editingId, setEditingId] = useState(null); + const [saving, setSaving] = useState(false); + const toast = useToast(); + + const emptyForm = { description: '', amount: '', recurrence: 'monthly', source: 'manual', confidence: '0.8' }; + const [form, setForm] = useState(emptyForm); const load = useCallback(async () => { try { @@ -38,6 +47,63 @@ export function RevenueSources() { } }; + const handleAdd = async () => { + if (!form.description || !form.amount) return; + setSaving(true); + try { + await api.addRevenueSource({ + description: form.description, + amount: form.amount, + recurrence: form.recurrence, + source: form.source, + confidence: form.confidence, + status: 'active', + }); + setShowAdd(false); + setForm(emptyForm); + await load(); + toast.success('Revenue source added', form.description); + } catch (e: unknown) { + toast.error('Add failed', e instanceof Error ? e.message : 'Unknown error'); + } finally { + setSaving(false); + } + }; + + const startEdit = (src: RevenueSource) => { + setEditingId(src.id); + setForm({ + description: src.description, + amount: src.amount, + recurrence: src.recurrence || 'monthly', + source: src.source, + confidence: src.confidence, + }); + }; + + const handleUpdate = async () => { + if (!editingId || !form.description || !form.amount) return; + setSaving(true); + try { + await api.updateRevenueSource(editingId, { + description: form.description, + amount: form.amount, + recurrence: form.recurrence, + confidence: form.confidence, + }); + setEditingId(null); + setForm(emptyForm); + await load(); + toast.success('Revenue source updated', form.description); + } catch (e: unknown) { + toast.error('Update failed', e instanceof Error ? e.message : 'Unknown error'); + } finally { + setSaving(false); + } + }; + + const recurrenceOptions = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annually', 'one-time']; + return (
@@ -47,21 +113,105 @@ export function RevenueSources() { {summary.count} sources | {formatCurrency(summary.weighted_monthly)}/mo (confidence-weighted)

- +
+ { setShowAdd(!showAdd); setEditingId(null); setForm(emptyForm); }} + /> + +
- {error && ( -

{error}

+ {error &&

{error}

} + + {/* Add / Edit form */} + {(showAdd || editingId) && ( + +
+

+ {editingId ? : } + {editingId ? 'Edit Revenue Source' : 'New Revenue Source'} +

+
+
+ + setForm(f => ({ ...f, description: e.target.value }))} + placeholder="e.g. Rental income - 550 W Surf" + className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-200 text-card-text text-sm focus:outline-none focus:ring-2 focus:ring-chitty-500/50" + /> +
+
+ + setForm(f => ({ ...f, amount: e.target.value }))} + placeholder="0.00" + className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-200 text-card-text text-sm focus:outline-none focus:ring-2 focus:ring-chitty-500/50" + /> +
+
+ + +
+
+ + setForm(f => ({ ...f, source: e.target.value }))} + placeholder="e.g. manual, plaid" + className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-200 text-card-text text-sm focus:outline-none" + /> +
+
+ + setForm(f => ({ ...f, confidence: e.target.value }))} + className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-200 text-card-text text-sm focus:outline-none" + /> +
+
+
+ { setShowAdd(false); setEditingId(null); setForm(emptyForm); }} + /> + +
+
+
)} {sources.length === 0 ? (

No revenue sources discovered yet.

-

Click "Discover from History" to scan transaction data.

+

Click "Auto-Discover" to scan transaction data or "Add Source" to enter manually.

) : (
@@ -94,6 +244,13 @@ export function RevenueSources() { : parseFloat(src.confidence) >= 0.6 ? 'bg-amber-500' : 'bg-red-500' }`} title={`${Math.round(parseFloat(src.confidence) * 100)}% confidence`} /> +
diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index c0a4f50..31a73cf 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -340,6 +340,39 @@ export const api = { spawnRecommendation: (id: string, data: { rec_type: string; priority?: number; action_type?: string; estimated_savings?: number }) => request<{ task_id: string; recommendation_id: string }>(`/tasks/${id}/spawn-recommendation`, { method: 'POST', body: JSON.stringify(data) }), + // Legal CRUD + createLegalDeadline: (data: { + case_ref: string; + deadline_type: string; + title: string; + deadline_date: string; + description?: string; + case_system?: string; + status?: string; + urgency_score?: number; + }) => + request('/legal', { method: 'POST', body: JSON.stringify(data) }), + updateLegalDeadline: (id: string, data: Partial<{ + status: string; + deadline_date: string; + urgency_score: number; + }>) => + request(`/legal/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + + // Evidence Timeline + getCaseTimeline: (caseId: string, params?: { start?: string; end?: string }) => { + const qs = new URLSearchParams( + Object.fromEntries(Object.entries(params || {}).filter(([, v]) => v != null)), + ).toString(); + return request(`/cases/${caseId}/timeline${qs ? '?' + qs : ''}`); + }, + getCaseFacts: (caseId: string) => + request<{ caseId: string; facts: TimelineFact[] }>(`/cases/${caseId}/facts`), + getCaseContradictions: (caseId: string) => + request<{ caseId: string; contradictions: Contradiction[] }>(`/cases/${caseId}/contradictions`), + getPendingFacts: (caseId: string, limit?: number) => + request<{ caseId: string; pending: TimelineFact[] }>(`/cases/${caseId}/pending-facts${limit ? '?limit=' + limit : ''}`), + // Litigation Assistant litigationSynthesize: (data: { rawNotes: string; property?: string; caseNumber?: string }) => request<{ synthesis: string }>('/litigation/synthesize', { method: 'POST', body: JSON.stringify(data) }), @@ -755,3 +788,51 @@ export interface TaskAction { status: string; executed_at: string; } + +// ── Evidence Timeline Types ───────────────────────────────── + +export interface TimelineEvent { + id: string; + date: string; + type: 'fact' | 'deadline' | 'dispute' | 'document'; + title: string; + description?: string; + source: string; + metadata?: Record; +} + +export interface TimelineResponse { + caseId: string; + eventCount: number; + dateRange: { earliest: string | null; latest: string | null }; + sources: { facts: number; deadlines: number; disputes: number; documents: number }; + events: TimelineEvent[]; + warnings?: string[]; + partial?: boolean; +} + +export interface TimelineFact { + id: string; + case_id: string; + fact_text: string; + fact_type?: string; + fact_date?: string; + confidence?: number; + verification_status?: string; + source_quote?: string; + document_id?: string; + entities?: { name: string; entity_type: string; role?: string }[]; + amounts?: { amount_value: number; currency: string; description?: string }[]; +} + +export interface Contradiction { + id: string; + fact_a_id: string; + fact_b_id: string; + fact_a_text: string; + fact_b_text: string; + contradiction_type: string; + severity: string; + resolution_status?: string; + explanation?: string; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 78105bd..e2e477e 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -13,6 +13,8 @@ import { CashFlow } from './pages/CashFlow'; import { Recommendations } from './pages/Recommendations'; import { Settings } from './pages/Settings'; import { ActionQueue } from './pages/ActionQueue'; +import { Evidence } from './pages/Evidence'; +import { Tasks } from './pages/Tasks'; import { Login } from './pages/Login'; import { isAuthenticated } from './lib/auth'; import { ToastProvider } from './lib/toast'; @@ -41,6 +43,8 @@ ReactDOM.createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> + } /> } /> } /> diff --git a/ui/src/pages/Accounts.tsx b/ui/src/pages/Accounts.tsx index f766543..dac03e2 100644 --- a/ui/src/pages/Accounts.tsx +++ b/ui/src/pages/Accounts.tsx @@ -1,16 +1,56 @@ import { useEffect, useState } from 'react'; -import { api, type Account } from '../lib/api'; +import { api, type Account, type Transaction } from '../lib/api'; import { Card } from '../components/ui/Card'; -import { formatCurrency } from '../lib/utils'; +import { ActionButton } from '../components/ui/ActionButton'; +import { formatCurrency, formatDate, cn } from '../lib/utils'; +import { useToast } from '../lib/toast'; +import { ChevronDown, ChevronUp, RefreshCw, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; export function Accounts() { const [accounts, setAccounts] = useState([]); const [error, setError] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [transactions, setTransactions] = useState([]); + const [txLoading, setTxLoading] = useState(false); + const [syncing, setSyncing] = useState(false); + const toast = useToast(); useEffect(() => { api.getAccounts().then(setAccounts).catch((e) => setError(e.message)); }, []); + const toggleExpand = async (id: string) => { + if (expandedId === id) { + setExpandedId(null); + setTransactions([]); + return; + } + setExpandedId(id); + setTxLoading(true); + try { + const data = await api.getAccount(id); + setTransactions(data.transactions || []); + } catch { + setTransactions([]); + } finally { + setTxLoading(false); + } + }; + + const syncBalances = async () => { + setSyncing(true); + try { + const result = await api.syncPlaidBalances(); + toast.success('Sync complete', `${result.accounts_updated} accounts updated`); + const refreshed = await api.getAccounts(); + setAccounts(refreshed); + } catch (e: unknown) { + toast.error('Sync failed', e instanceof Error ? e.message : 'Unknown error'); + } finally { + setSyncing(false); + } + }; + if (error) return

{error}

; const grouped = accounts.reduce>((acc, a) => { @@ -33,39 +73,95 @@ export function Accounts() { return (
-

Accounts

+
+

Accounts

+ +
{Object.entries(grouped).map(([type, accts]) => (

{typeLabels[type] || type}

-
+
{accts.map((a) => ( - -
-
-

{a.account_name}

-

{a.institution}

+
+ toggleExpand(a.id)}> +
+
+

{a.account_name}

+
+

{a.institution}

+ {a.last_synced_at && ( + Synced {formatDate(a.last_synced_at)} + )} +
+
+
+

+ {formatCurrency(a.current_balance)} +

+ {expandedId === a.id ? : } +
-

- {formatCurrency(a.current_balance)} -

-
- {a.credit_limit && ( -
-
-
+ {a.credit_limit && ( +
+
+
+
+

+ {formatCurrency(a.current_balance)} / {formatCurrency(a.credit_limit)} + {a.interest_rate && {a.interest_rate}% APR} +

-

- {formatCurrency(a.current_balance)} / {formatCurrency(a.credit_limit)} -

+ )} + + + {/* Expanded transaction list */} + {expandedId === a.id && ( +
+ {txLoading ? ( +

Loading transactions...

+ ) : transactions.length === 0 ? ( +

No recent transactions

+ ) : ( +
+

Recent Transactions

+ {transactions.slice(0, 20).map((tx) => ( +
+
+ {tx.direction === 'credit' ? ( + + ) : ( + + )} + {tx.description} +
+
+ + {tx.direction === 'credit' ? '+' : '-'}{formatCurrency(tx.amount)} + + {formatDate(tx.tx_date)} +
+
+ ))} + {transactions.length > 20 && ( +

+ Showing 20 of {transactions.length} transactions +

+ )} +
+ )}
)} - +
))}
diff --git a/ui/src/pages/ActionQueue.tsx b/ui/src/pages/ActionQueue.tsx index c848dfb..213eb81 100644 --- a/ui/src/pages/ActionQueue.tsx +++ b/ui/src/pages/ActionQueue.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import { api, type QueueItem, type QueueStats } from '../lib/api'; +import { api, type QueueItem, type QueueStats, type DecisionHistory } from '../lib/api'; import { SwipeStack } from '../components/swipe/SwipeStack'; import { SwipeStatsBar } from '../components/swipe/SwipeStatsBar'; import { DesktopControls } from '../components/swipe/DesktopControls'; @@ -7,6 +7,10 @@ import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { ActionButton } from '../components/ui/ActionButton'; import { Card } from '../components/ui/Card'; import { useToast } from '../lib/toast'; +import { formatDate, formatCurrency, cn } from '../lib/utils'; +import { History, CheckCircle, XCircle, Clock } from 'lucide-react'; + +type QueueTab = 'active' | 'history'; export function ActionQueue() { const [items, setItems] = useState([]); @@ -14,6 +18,9 @@ export function ActionQueue() { const [loading, setLoading] = useState(true); const [generating, setGenerating] = useState(false); const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('active'); + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); const sessionId = useRef(crypto.randomUUID()); const autoTriageAttempted = useRef(false); const toast = useToast(); @@ -82,11 +89,27 @@ export function ActionQueue() { } }, []); + const loadHistory = useCallback(async () => { + setHistoryLoading(true); + try { + const data = await api.getQueueHistory(50); + setHistory(data); + } catch { + // History is non-critical + } finally { + setHistoryLoading(false); + } + }, []); + useEffect(() => { loadQueue(); loadStats(); }, [loadQueue, loadStats]); + useEffect(() => { + if (activeTab === 'history' && history.length === 0) loadHistory(); + }, [activeTab, history.length, loadHistory]); + const isPaymentAction = useCallback((item: QueueItem) => { const type = item.action_type; return type === 'pay_now' || type === 'pay_full' || type === 'pay_minimum'; @@ -150,15 +173,47 @@ export function ActionQueue() { return
Loading action queue...
; } + const decisionIcon = (decision: string) => { + if (decision === 'approved') return ; + if (decision === 'rejected') return ; + return ; + }; + return (

Action Queue

- +
+ {activeTab === 'active' && ( + + )} +
+
+ + {/* Tabs */} +
+ +
{error && ( @@ -167,39 +222,87 @@ export function ActionQueue() { )} - {/* Stats bar */} - {stats.total > 0 && ( - - )} + {/* Active tab */} + {activeTab === 'active' && ( + <> + {/* Stats bar */} + {stats.total > 0 && ( + + )} + + {/* Swipe stack */} + loadQueue(false)} + /> + + {/* Desktop controls */} + {items.length > 0 && ( + currentItem && handleDecide(currentItem.id, 'approved')} + onReject={() => currentItem && handleDecide(currentItem.id, 'rejected')} + onDefer={() => currentItem && handleDecide(currentItem.id, 'deferred')} + disabled={!currentItem} + /> + )} - {/* Swipe stack */} - loadQueue(false)} - /> - - {/* Desktop controls */} - {items.length > 0 && ( - currentItem && handleDecide(currentItem.id, 'approved')} - onReject={() => currentItem && handleDecide(currentItem.id, 'rejected')} - onDefer={() => currentItem && handleDecide(currentItem.id, 'deferred')} - disabled={!currentItem} - /> + {/* Mobile swipe hint */} + {items.length > 0 && ( +

+ Swipe right to approve, left to reject, up to defer +

+ )} + )} - {/* Mobile swipe hint */} - {items.length > 0 && ( -

- Swipe right to approve, left to reject, up to defer -

+ {/* History tab */} + {activeTab === 'history' && ( +
+ {historyLoading ? ( +

Loading history...

+ ) : history.length === 0 ? ( + +

No decision history yet.

+

Decisions you make in the Action Queue will appear here.

+
+ ) : ( + history.map((h) => ( + +
+
+ {decisionIcon(h.decision)} +
+

{h.title || 'Untitled'}

+
+ {h.decision} + {h.rec_type && {h.rec_type}} + {h.obligation_payee && {h.obligation_payee}} +
+
+
+
+ {h.estimated_savings && parseFloat(h.estimated_savings) > 0 && ( +

{formatCurrency(h.estimated_savings)}

+ )} +

{formatDate(h.created_at)}

+
+
+
+ )) + )} +
)}
); diff --git a/ui/src/pages/Evidence.tsx b/ui/src/pages/Evidence.tsx new file mode 100644 index 0000000..189722d --- /dev/null +++ b/ui/src/pages/Evidence.tsx @@ -0,0 +1,215 @@ +import { useState, useCallback } from 'react'; +import { api, type TimelineEvent, type TimelineResponse, type Contradiction } from '../lib/api'; +import { Card } from '../components/ui/Card'; +import { ActionButton } from '../components/ui/ActionButton'; +import { MetricCard } from '../components/ui/MetricCard'; +import { formatDate, cn } from '../lib/utils'; +import { Search, AlertTriangle, FileText, Scale, Calendar, ShieldAlert } from 'lucide-react'; + +type EvidenceTab = 'timeline' | 'contradictions'; + +const EVENT_TYPE_STYLES: Record = { + fact: { icon: FileText, bg: 'bg-blue-100', text: 'text-blue-700' }, + deadline: { icon: Calendar, bg: 'bg-purple-100', text: 'text-purple-700' }, + dispute: { icon: ShieldAlert, bg: 'bg-orange-100', text: 'text-orange-700' }, + document: { icon: FileText, bg: 'bg-green-100', text: 'text-green-700' }, +}; + +export function Evidence() { + const [caseId, setCaseId] = useState(''); + const [timeline, setTimeline] = useState(null); + const [contradictions, setContradictions] = useState([]); + const [activeTab, setActiveTab] = useState('timeline'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadTimeline = useCallback(async () => { + if (!caseId.trim()) return; + setLoading(true); + setError(null); + try { + const [tl, ctr] = await Promise.all([ + api.getCaseTimeline(caseId), + api.getCaseContradictions(caseId).catch(() => ({ caseId, contradictions: [] })), + ]); + setTimeline(tl); + setContradictions(ctr.contradictions); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to load timeline'); + } finally { + setLoading(false); + } + }, [caseId]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + loadTimeline(); + }; + + return ( +
+
+ +
+

Evidence Timeline

+

Unified case timeline from ChittyEvidence, deadlines, and disputes

+
+
+ + {/* Case ID Input */} +
+
+ + setCaseId(e.target.value)} + placeholder="Enter case ID (e.g. CC-DISPUTE-abc12345)" + className="w-full pl-9 pr-3 py-2 rounded-xl bg-card-bg border border-card-border text-card-text text-sm focus:outline-none focus:ring-2 focus:ring-chitty-500/50" + /> +
+ + + + {error && ( +
+ {error} +
+ )} + + {timeline && ( + <> + {/* Summary Metrics */} +
+ + + + + 0 ? 'text-urgency-red' : 'text-urgency-green'} /> +
+ + {timeline.warnings && timeline.warnings.length > 0 && ( +
+ {timeline.warnings.map((w, i) =>

{w}

)} +
+ )} + + {/* Tabs */} +
+ {(['timeline', 'contradictions'] as EvidenceTab[]).map((tab) => ( + + ))} +
+ + {/* Timeline View */} + {activeTab === 'timeline' && ( +
+ {/* Vertical line */} +
+ +
+ {timeline.events.map((event) => ( + + ))} +
+ + {timeline.events.length === 0 && ( + +

No timeline events found for this case.

+
+ )} +
+ )} + + {/* Contradictions View */} + {activeTab === 'contradictions' && ( +
+ {contradictions.length === 0 ? ( + +

No contradictions detected.

+
+ ) : ( + contradictions.map((c) => ( + +
+
+ + {c.contradiction_type} + {c.severity} + {c.resolution_status && ( + {c.resolution_status} + )} +
+
+
+

Fact A

+

{c.fact_a_text}

+
+
+

Fact B

+

{c.fact_b_text}

+
+
+ {c.explanation && ( +

{c.explanation}

+ )} +
+
+ )) + )} +
+ )} + + )} + + {!timeline && !loading && ( + + +

Enter a case ID to view the unified evidence timeline.

+

Combines facts, deadlines, disputes, and documents from across the ecosystem.

+
+ )} +
+ ); +} + +function TimelineEventCard({ event }: { event: TimelineEvent }) { + const style = EVENT_TYPE_STYLES[event.type] || EVENT_TYPE_STYLES.fact; + const Icon = style.icon; + + return ( +
+
+ +
+ +
+
+
+ + {event.type} + + {event.source} + {event.metadata?.confidence != null && ( + {Math.round(Number(event.metadata.confidence) * 100)}% confidence + )} +
+

{event.title}

+ {event.description && ( +

{event.description}

+ )} +
+ {formatDate(event.date)} +
+
+
+ ); +} diff --git a/ui/src/pages/Legal.tsx b/ui/src/pages/Legal.tsx index a9eb1d8..1f3a8ec 100644 --- a/ui/src/pages/Legal.tsx +++ b/ui/src/pages/Legal.tsx @@ -1,18 +1,63 @@ import { useEffect, useState } from 'react'; import { api, type LegalDeadline } from '../lib/api'; -import { formatDate, daysUntil } from '../lib/utils'; +import { formatDate, daysUntil, cn } from '../lib/utils'; import { Card } from '../components/ui/Card'; +import { ActionButton } from '../components/ui/ActionButton'; +import { useToast } from '../lib/toast'; import { Link } from 'react-router-dom'; +import { Plus, X, CheckCircle } from 'lucide-react'; export function Legal() { const [deadlines, setDeadlines] = useState([]); const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [creating, setCreating] = useState(false); + const toast = useToast(); - useEffect(() => { + // Create form state + const [form, setForm] = useState({ + case_ref: '', + deadline_type: 'filing', + title: '', + deadline_date: '', + description: '', + }); + + const load = () => { api.getLegalDeadlines().then(setDeadlines).catch((e) => setError(e.message)); - }, []); + }; - if (error) return
{error}
; + useEffect(load, []); + + const handleCreate = async () => { + if (!form.case_ref || !form.title || !form.deadline_date) return; + setCreating(true); + try { + await api.createLegalDeadline(form); + setShowCreate(false); + setForm({ case_ref: '', deadline_type: 'filing', title: '', deadline_date: '', description: '' }); + load(); + toast.success('Deadline created', form.title); + } catch (e: unknown) { + toast.error('Create failed', e instanceof Error ? e.message : 'Unknown error'); + } finally { + setCreating(false); + } + }; + + const markComplete = async (id: string) => { + try { + await api.updateLegalDeadline(id, { status: 'completed' }); + setDeadlines(prev => prev.filter(d => d.id !== id)); + toast.success('Deadline completed', 'Marked as done'); + } catch (e: unknown) { + toast.error('Update failed', e instanceof Error ? e.message : 'Unknown error'); + } + }; + + if (error && deadlines.length === 0) { + return
{error}
; + } const urgencyFromDays = (days: number): 'red' | 'amber' | 'green' | null => { if (days < 0) return 'red'; @@ -28,9 +73,87 @@ export function Legal() { return 'text-card-muted'; }; + const deadlineTypes = ['filing', 'hearing', 'response', 'discovery', 'motion', 'trial', 'appeal', 'other']; + return (
-

Legal Deadlines

+
+

Legal Deadlines

+ setShowCreate(!showCreate)} + /> +
+ + {/* Create form */} + {showCreate && ( + +
+
+ +

New Deadline

+
+
+
+ + setForm(f => ({ ...f, case_ref: e.target.value }))} + placeholder="e.g. 2024D007847" + className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-200 text-card-text text-sm focus:outline-none focus:ring-2 focus:ring-chitty-500/50" + /> +
+
+ + +
+
+ + setForm(f => ({ ...f, title: e.target.value }))} + placeholder="e.g. Response to Motion to Dismiss" + className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-200 text-card-text text-sm focus:outline-none focus:ring-2 focus:ring-chitty-500/50" + /> +
+
+ + setForm(f => ({ ...f, deadline_date: e.target.value }))} + className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-200 text-card-text text-sm focus:outline-none focus:ring-2 focus:ring-chitty-500/50" + /> +
+
+ + setForm(f => ({ ...f, description: e.target.value }))} + placeholder="Optional details" + className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-200 text-card-text text-sm focus:outline-none focus:ring-2 focus:ring-chitty-500/50" + /> +
+
+
+ setShowCreate(false)} /> + +
+
+
+ )}
{deadlines.map((dl) => { @@ -40,8 +163,8 @@ export function Legal() { return (
-
-
+
+
{dl.deadline_type} @@ -57,11 +180,20 @@ export function Legal() {

{dl.title}

-
-

- {isPast ? `${Math.abs(days)}d PAST` : days === 0 ? 'TODAY' : `${days}d`} -

-

{formatDate(dl.deadline_date)}

+
+
+

+ {isPast ? `${Math.abs(days)}d PAST` : days === 0 ? 'TODAY' : `${days}d`} +

+

{formatDate(dl.deadline_date)}

+
+
@@ -69,9 +201,10 @@ export function Legal() { })}
- {deadlines.length === 0 && ( + {deadlines.length === 0 && !showCreate && (

No upcoming legal deadlines

+

Click "Add Deadline" to create one.

)}
diff --git a/ui/src/pages/LitigationAssistant.tsx b/ui/src/pages/LitigationAssistant.tsx index bf65329..efaae1d 100644 --- a/ui/src/pages/LitigationAssistant.tsx +++ b/ui/src/pages/LitigationAssistant.tsx @@ -1,9 +1,10 @@ -import { useState, useRef } from 'react'; -import { api } from '../lib/api'; +import { useState, useRef, useEffect } from 'react'; +import { api, type Dispute } from '../lib/api'; import { Card } from '../components/ui/Card'; +import { useToast } from '../lib/toast'; import { Scale, FileText, Shield, AlertTriangle, CheckCircle, - Loader2, ChevronRight, Copy, Check, + Loader2, ChevronRight, Copy, Check, Save, Link2, } from 'lucide-react'; type Step = 'idle' | 'synthesizing' | 'synthesized' | 'drafting' | 'drafted' | 'scanning' | 'scanned'; @@ -38,9 +39,55 @@ export function LitigationAssistant() { const [error, setError] = useState(null); const [copied, setCopied] = useState(false); + // Dispute bridge state + const [disputes, setDisputes] = useState([]); + const [selectedDisputeId, setSelectedDisputeId] = useState(''); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const toast = useToast(); + const synthesisRef = useRef(null); const draftRef = useRef(null); + // Load disputes for the picker + useEffect(() => { + api.getDisputes().then(setDisputes).catch(() => {}); + }, []); + + // Pre-populate from dispute context if URL has ?dispute=ID + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const disputeId = params.get('dispute'); + if (disputeId && disputes.length > 0) { + setSelectedDisputeId(disputeId); + const d = disputes.find(dd => dd.id === disputeId); + if (d) { + if (d.counterparty) setRecipient(d.counterparty); + if (d.description) setRawNotes(prev => prev || d.description || ''); + } + } + }, [disputes]); + + const saveToDispute = async () => { + if (!selectedDisputeId || !draft) return; + setSaving(true); + try { + await api.addCorrespondence(selectedDisputeId, { + direction: 'outbound', + channel: 'email', + subject: `Draft: ${focus}`, + content: draft, + }); + setSaved(true); + toast.success('Saved to dispute', 'Draft added as correspondence'); + setTimeout(() => setSaved(false), 3000); + } catch (e: unknown) { + toast.error('Save failed', e instanceof Error ? e.message : 'Unknown error'); + } finally { + setSaving(false); + } + }; + const handleSynthesize = async () => { if (!rawNotes.trim()) return; setStep('synthesizing'); @@ -276,17 +323,45 @@ export function LitigationAssistant() {
{draft && ( - + <> + + + )} Step 3
+ {/* Dispute picker */} + {draft && ( +
+ + +
+ )} {draft ? (
{draft} diff --git a/ui/src/pages/Recommendations.tsx b/ui/src/pages/Recommendations.tsx index bc05c9b..c93f86e 100644 --- a/ui/src/pages/Recommendations.tsx +++ b/ui/src/pages/Recommendations.tsx @@ -3,7 +3,14 @@ import { api, type Recommendation } from '../lib/api'; import { Card } from '../components/ui/Card'; import { MetricCard } from '../components/ui/MetricCard'; import { ActionButton } from '../components/ui/ActionButton'; -import { formatCurrency } from '../lib/utils'; +import { formatCurrency, cn } from '../lib/utils'; +import { useToast } from '../lib/toast'; +import { Calendar, Mail, Globe, Clock, X } from 'lucide-react'; + +type FollowThrough = { + recId: string; + type: string; +}; export function Recommendations() { const [recs, setRecs] = useState([]); @@ -15,6 +22,9 @@ export function Recommendations() { cash_position: { total_cash: number; total_due_30d: number; surplus: number }; } | null>(null); const [error, setError] = useState(null); + const [followThrough, setFollowThrough] = useState(null); + const [deferDate, setDeferDate] = useState(''); + const toast = useToast(); const loadRecs = async () => { try { @@ -44,23 +54,46 @@ export function Recommendations() { }; const act = async (id: string, action: string) => { + // Actions that need follow-through UI + const needsFollowThrough = ['defer', 'send_email', 'execute_browser', 'negotiate']; + if (needsFollowThrough.includes(action)) { + setFollowThrough({ recId: id, type: action }); + return; + } + try { await api.actOnRecommendation(id, { action_taken: action }); setRecs(recs.filter(r => r.id !== id)); + toast.success('Action taken', `Recommendation marked as ${action}`); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Action failed'; - console.error(`[Recommendations] act failed for ${id}:`, msg, e); setError(msg); } }; + const confirmFollowThrough = async () => { + if (!followThrough) return; + const action = followThrough.type === 'defer' && deferDate + ? `deferred_until:${deferDate}` + : followThrough.type; + + try { + await api.actOnRecommendation(followThrough.recId, { action_taken: action }); + setRecs(recs.filter(r => r.id !== followThrough.recId)); + toast.success('Action completed', `${followThrough.type} action recorded`); + setFollowThrough(null); + setDeferDate(''); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Action failed'); + } + }; + const dismiss = async (id: string) => { try { await api.dismissRecommendation(id); setRecs(recs.filter(r => r.id !== id)); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Dismiss failed'; - console.error(`[Recommendations] dismiss failed for ${id}:`, msg, e); setError(msg); } }; @@ -89,11 +122,21 @@ export function Recommendations() { const labels: Record = { pay_now: 'Pay Now', pay_minimum: 'Pay Minimum', negotiate: 'Start Negotiation', defer: 'Defer', execute_action: 'Execute', plan_action: 'Plan', - prepare_legal: 'Prepare', review_cashflow: 'Review', execute_browser: 'Automate', send_email: 'Send', + prepare_legal: 'Prepare', review_cashflow: 'Review', execute_browser: 'Automate', send_email: 'Send Email', }; return labels[type || ''] || 'Act'; }; + const actionIcon = (type: string | null) => { + if (type === 'defer') return Calendar; + if (type === 'send_email') return Mail; + if (type === 'execute_browser') return Globe; + if (type === 'negotiate') return Clock; + return null; + }; + + const activeRec = followThrough ? recs.find(r => r.id === followThrough.recId) : null; + return (
@@ -118,6 +161,69 @@ export function Recommendations() {
)} + {/* Follow-through panel */} + {followThrough && activeRec && ( + +
+
+

+ {followThrough.type === 'defer' && 'Set Deferral Date'} + {followThrough.type === 'send_email' && 'Email Action'} + {followThrough.type === 'execute_browser' && 'Browser Automation'} + {followThrough.type === 'negotiate' && 'Negotiation Plan'} +

+ +
+ +

{activeRec.title}

+ + {followThrough.type === 'defer' && ( +
+ setDeferDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + className="px-3 py-2 rounded-lg bg-slate-50 border border-slate-200 text-card-text text-sm focus:outline-none focus:ring-2 focus:ring-chitty-500/50" + /> + +
+ )} + + {followThrough.type === 'send_email' && ( +
+

This will record the email action. Use Litigation AI to draft the email first.

+
+ + { window.location.href = '/litigation'; }} /> +
+
+ )} + + {followThrough.type === 'execute_browser' && ( +
+

Browser automation tasks are queued for Claude in Chrome execution.

+ +
+ )} + + {followThrough.type === 'negotiate' && ( +
+

Record that you've initiated negotiation. Track progress in the dispute.

+
+ + {activeRec.dispute_title && ( + { window.location.href = '/disputes'; }} /> + )} +
+
+ )} +
+
+ )} + {recs.length === 0 ? (

No active recommendations.

@@ -125,37 +231,40 @@ export function Recommendations() {
) : (
- {recs.map((rec) => ( - -
-
-
- - P{rec.priority} - - - {rec.rec_type} - - {rec.obligation_payee && {rec.obligation_payee}} - {rec.dispute_title && {rec.dispute_title}} + {recs.map((rec) => { + const Icon = actionIcon(rec.action_type); + return ( + +
+
+
+ + P{rec.priority} + + + {rec.rec_type} + + {rec.obligation_payee && {rec.obligation_payee}} + {rec.dispute_title && {rec.dispute_title}} +
+

{rec.title}

+

{rec.reasoning}

+
+
+ act(rec.id, rec.action_type || 'acted')} + /> + dismiss(rec.id)} + />
-

{rec.title}

-

{rec.reasoning}

-
-
- act(rec.id, rec.action_type || 'acted')} - /> - dismiss(rec.id)} - />
-
- - ))} + + ); + })}
)}
diff --git a/ui/src/pages/Tasks.tsx b/ui/src/pages/Tasks.tsx new file mode 100644 index 0000000..1e52b43 --- /dev/null +++ b/ui/src/pages/Tasks.tsx @@ -0,0 +1,224 @@ +import { useEffect, useState, useCallback } from 'react'; +import { api, type Task } from '../lib/api'; +import { Card } from '../components/ui/Card'; +import { ActionButton } from '../components/ui/ActionButton'; +import { formatDate, cn } from '../lib/utils'; +import { useToast } from '../lib/toast'; +import { CheckCircle, Clock, AlertCircle, Loader2, ExternalLink } from 'lucide-react'; + +type StatusFilter = 'all' | 'pending' | 'in_progress' | 'done' | 'verified' | 'failed'; + +const STATUS_COLUMNS: { key: StatusFilter; label: string; color: string }[] = [ + { key: 'pending', label: 'Pending', color: 'border-t-amber-400' }, + { key: 'in_progress', label: 'In Progress', color: 'border-t-blue-400' }, + { key: 'done', label: 'Done', color: 'border-t-green-400' }, + { key: 'verified', label: 'Verified', color: 'border-t-emerald-400' }, +]; + +const STATUS_BADGE: Record = { + pending: 'bg-amber-100 text-amber-700', + in_progress: 'bg-blue-100 text-blue-700', + done: 'bg-green-100 text-green-700', + verified: 'bg-emerald-100 text-emerald-700', + failed: 'bg-red-100 text-red-700', + blocked: 'bg-gray-100 text-gray-700', +}; + +export function Tasks() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [view, setView] = useState<'kanban' | 'list'>('kanban'); + const [filter, setFilter] = useState('all'); + const [updatingId, setUpdatingId] = useState(null); + const toast = useToast(); + + const loadTasks = useCallback(async () => { + try { + const params: { status?: string; limit: number } = { limit: 100 }; + if (filter !== 'all') params.status = filter; + const data = await api.getTasks(params); + setTasks(data.tasks); + setError(null); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to load tasks'); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { loadTasks(); }, [loadTasks]); + + const updateStatus = async (id: string, status: string) => { + setUpdatingId(id); + try { + await api.updateTaskStatus(id, status); + setTasks((prev) => prev.map((t) => t.id === id ? { ...t, backend_status: status } : t)); + toast.success('Task updated', `Status changed to ${status}`); + } catch (e: unknown) { + toast.error('Update failed', e instanceof Error ? e.message : 'Unknown error'); + } finally { + setUpdatingId(null); + } + }; + + if (loading) return
Loading tasks...
; + + const grouped = tasks.reduce>((acc, t) => { + const key = t.backend_status; + if (!acc[key]) acc[key] = []; + acc[key].push(t); + return acc; + }, {}); + + return ( +
+
+

Task Board

+
+
+ + +
+ +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* List view filter */} + {view === 'list' && ( +
+ {(['all', 'pending', 'in_progress', 'done', 'verified', 'failed'] as StatusFilter[]).map((f) => ( + + ))} +
+ )} + + {/* Kanban View */} + {view === 'kanban' && ( +
+ {STATUS_COLUMNS.map((col) => ( +
+
+

{col.label}

+ + {(grouped[col.key] || []).length} + +
+
+ {(grouped[col.key] || []).map((task) => ( + + ))} + {!(grouped[col.key] || []).length && ( +

No tasks

+ )} +
+
+ ))} +
+ )} + + {/* List View */} + {view === 'list' && ( +
+ {tasks.length === 0 ? ( + +

No tasks found.

+
+ ) : ( + tasks.map((task) => ( + + )) + )} +
+ )} +
+ ); +} + +function TaskCard({ + task, onUpdateStatus, updatingId, compact, +}: { + task: Task; + onUpdateStatus: (id: string, status: string) => void; + updatingId: string | null; + compact?: boolean; +}) { + const isUpdating = updatingId === task.id; + const nextStatus: Record = { + pending: 'in_progress', + in_progress: 'done', + done: 'verified', + }; + const next = nextStatus[task.backend_status]; + + const priorityColor = task.priority <= 2 ? 'text-red-600' : task.priority <= 3 ? 'text-amber-600' : 'text-card-muted'; + + return ( + +
+
+
+
+ P{task.priority} + + {task.backend_status.replace('_', ' ')} + + {task.task_type} +
+

{task.title}

+ {!compact && task.description && ( +

{task.description}

+ )} +
+ {task.notion_page_id && ( + + + + )} +
+ {!compact && ( +
+ {task.due_date && {formatDate(task.due_date)}} + {task.assigned_to && Assigned: {task.assigned_to}} + {task.source} +
+ )} + {next && ( + + )} +
+
+ ); +}