Skip to content

Commit 424f3ad

Browse files
chitcommitclaude
andcommitted
feat: dispute UI enhancements — Notion badges, triage colors, sync button, deadline countdown, clickable stage pills
- Notion sync badge (linked/unlinked) with clickable Notion URL on dispute cards - Triage severity coloring on priority badges (CRITICAL/HIGH/MEDIUM/LOW) - "Sync Notion" button in disputes header with sync status display - Deadline countdown badges with urgency coloring (overdue, <=3d, <=7d) - Enhanced deadlines panel: sorted by date, overdue highlighting, countdown labels - Clickable stage pills replacing "Advance Stage" button - Notion indicator dot on dashboard DisputesWidget - ProgressDots tint prop for stage-specific coloring - syncDisputesNotion API method - daysUntil utility function Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 414bc0b commit 424f3ad

4 files changed

Lines changed: 212 additions & 34 deletions

File tree

ui/src/components/dashboard/DisputesWidget.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,16 @@ export function DisputesWidget({ disputes }: Props) {
3232
<Card key={d.id} urgency={d.priority <= 1 ? 'red' : d.priority <= 3 ? 'amber' : 'green'}>
3333
<div className="flex items-start justify-between gap-2">
3434
<div className="min-w-0 flex-1">
35-
<p className="font-medium text-card-text truncate">{d.title}</p>
36-
<p className="text-card-muted text-xs">vs {d.counterparty}</p>
35+
<div className="flex items-center gap-1.5">
36+
<span
37+
className={`inline-block w-2 h-2 rounded-full shrink-0 ${
38+
d.metadata?.notion_task_id ? 'bg-green-500' : 'bg-gray-300'
39+
}`}
40+
title={d.metadata?.notion_task_id ? 'Synced to Notion' : 'Not synced to Notion'}
41+
/>
42+
<p className="font-medium text-card-text truncate">{d.title}</p>
43+
</div>
44+
<p className="text-card-muted text-xs ml-3.5">vs {d.counterparty}</p>
3745
<ProgressDots completed={disputeStageIndex(d.status) + 1} total={DISPUTE_STAGES.length} className="mt-2" />
3846
</div>
3947
<div className="text-right shrink-0">

ui/src/components/ui/ProgressDots.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
11
import { cn } from '../../lib/utils';
22

3+
export type ProgressDotsTint = 'default' | 'amber' | 'blue' | 'green';
4+
35
interface ProgressDotsProps {
46
completed: number;
57
total: number;
8+
tint?: ProgressDotsTint;
69
className?: string;
710
}
811

9-
export function ProgressDots({ completed, total, className }: ProgressDotsProps) {
12+
const TINT_FILLED: Record<ProgressDotsTint, string> = {
13+
default: 'bg-urgency-green',
14+
amber: 'bg-urgency-amber',
15+
blue: 'bg-chitty-400',
16+
green: 'bg-urgency-green',
17+
};
18+
19+
const TINT_LABEL: Record<ProgressDotsTint, string> = {
20+
default: 'text-card-muted',
21+
amber: 'text-urgency-amber',
22+
blue: 'text-chitty-500',
23+
green: 'text-urgency-green',
24+
};
25+
26+
export function ProgressDots({ completed, total, tint = 'default', className }: ProgressDotsProps) {
1027
const safeTotal = Math.max(1, Math.floor(total));
1128
const safeCompleted = Math.max(0, Math.min(Math.floor(completed), safeTotal));
1229

@@ -16,12 +33,12 @@ export function ProgressDots({ completed, total, className }: ProgressDotsProps)
1633
<span
1734
key={i}
1835
className={cn(
19-
'w-2 h-2 rounded-full',
20-
i < safeCompleted ? 'bg-urgency-green' : 'bg-card-border',
36+
'w-2 h-2 rounded-full transition-colors duration-200',
37+
i < safeCompleted ? TINT_FILLED[tint] : 'bg-card-border',
2138
)}
2239
/>
2340
))}
24-
<span className="text-xs text-card-muted ml-1">
41+
<span className={cn('text-xs ml-1', TINT_LABEL[tint])}>
2542
{safeCompleted}/{safeTotal}
2643
</span>
2744
</div>

ui/src/lib/api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,10 @@ export const api = {
217217
request<{ total: number; synced: number }>('/bridge/ledger/sync-documents', { method: 'POST' }),
218218
syncLedgerDisputes: () =>
219219
request<{ total: number; synced: number }>('/bridge/ledger/sync-disputes', { method: 'POST' }),
220+
syncDisputesNotion: (direction: 'to_notion' | 'from_notion' | 'both' = 'both') =>
221+
request<{ pushed: number; reconciled: number; direction: string; duration_ms: number }>(
222+
'/bridge/disputes/sync-notion', { method: 'POST', body: JSON.stringify({ direction }) },
223+
),
220224

221225
// Cash Flow
222226
getCashflowProjections: () =>
@@ -482,6 +486,7 @@ export interface Dispute {
482486
description: string | null;
483487
next_action: string | null;
484488
next_action_date: string | null;
489+
metadata?: Record<string, unknown> | null;
485490
}
486491

487492
export interface Correspondence {

ui/src/pages/Disputes.tsx

Lines changed: 176 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
22
import { api, type Dispute, type Correspondence, type LegalDeadline } from '../lib/api';
33
import { Card } from '../components/ui/Card';
44
import { ActionButton } from '../components/ui/ActionButton';
5-
import { ProgressDots } from '../components/ui/ProgressDots';
6-
import { formatCurrency, formatDate } from '../lib/utils';
5+
import { formatCurrency, formatDate, daysUntil } from '../lib/utils';
76
import { useToast } from '../lib/toast';
87
import { useSearchParams } from 'react-router-dom';
98

@@ -43,6 +42,20 @@ function getNextStage(current: DisputeStage): DisputeStage {
4342
return DISPUTE_STAGES[idx + 1];
4443
}
4544

45+
/** Urgency color classes for countdown badges: red <=3d, amber <=7d, green otherwise */
46+
function countdownClasses(days: number): { bg: string; text: string } {
47+
if (days < 0) return { bg: 'bg-red-600', text: 'text-white' };
48+
if (days <= 3) return { bg: 'bg-red-100', text: 'text-red-700' };
49+
if (days <= 7) return { bg: 'bg-amber-100', text: 'text-amber-700' };
50+
return { bg: 'bg-green-100', text: 'text-green-700' };
51+
}
52+
53+
function countdownLabel(days: number): string {
54+
if (days < 0) return `${Math.abs(days)}d ago`;
55+
if (days === 0) return 'TODAY';
56+
return `${days}d`;
57+
}
58+
4659
export function Disputes() {
4760
const [searchParams] = useSearchParams();
4861
const [disputes, setDisputes] = useState<Dispute[]>([]);
@@ -75,6 +88,9 @@ export function Disputes() {
7588
next_action: '',
7689
next_action_date: '',
7790
});
91+
const [syncing, setSyncing] = useState(false);
92+
const [lastSync, setLastSync] = useState<{ pushed: number; reconciled: number; duration_ms: number; at: Date } | null>(null);
93+
const [showSyncStatus, setShowSyncStatus] = useState(false);
7894
const toast = useToast();
7995
const autoExpandedRef = useRef<string | null>(null);
8096

@@ -230,17 +246,49 @@ export function Disputes() {
230246
return 'green';
231247
};
232248

249+
const syncNotion = async () => {
250+
setSyncing(true);
251+
try {
252+
const result = await api.syncDisputesNotion('both');
253+
const syncResult = { pushed: result.pushed, reconciled: result.reconciled, duration_ms: result.duration_ms, at: new Date() };
254+
setLastSync(syncResult);
255+
setShowSyncStatus(true);
256+
toast.success('Notion sync complete', `Pushed ${result.pushed}, reconciled ${result.reconciled} in ${result.duration_ms}ms`, { durationMs: 3000 });
257+
reload();
258+
} catch (e: unknown) {
259+
const msg = e instanceof Error ? e.message : 'Sync failed';
260+
toast.error('Notion sync failed', msg);
261+
} finally {
262+
setSyncing(false);
263+
}
264+
};
265+
233266
return (
234267
<div className="space-y-4">
235268
<div className="flex items-center justify-between gap-3">
236269
<h1 className="text-lg lg:text-xl font-bold text-chrome-text">Active Disputes</h1>
237-
<ActionButton
238-
label={showCreateForm ? 'Close Form' : 'New Dispute'}
239-
variant={showCreateForm ? 'secondary' : 'primary'}
240-
onClick={() => setShowCreateForm((v) => !v)}
241-
/>
270+
<div className="flex gap-2">
271+
<ActionButton
272+
label={syncing ? 'Syncing...' : 'Sync Notion'}
273+
variant="secondary"
274+
onClick={syncNotion}
275+
loading={syncing}
276+
/>
277+
<ActionButton
278+
label={showCreateForm ? 'Close Form' : 'New Dispute'}
279+
variant={showCreateForm ? 'secondary' : 'primary'}
280+
onClick={() => setShowCreateForm((v) => !v)}
281+
/>
282+
</div>
242283
</div>
243284

285+
{showSyncStatus && lastSync && (
286+
<div className="flex items-center justify-between text-xs text-card-muted bg-card-hover border border-card-border rounded-lg px-3 py-2">
287+
<span>Last sync: {lastSync.pushed} pushed, {lastSync.reconciled} reconciled ({lastSync.duration_ms}ms)</span>
288+
<button onClick={() => setShowSyncStatus(false)} className="text-card-muted hover:text-card-text ml-2">&times;</button>
289+
</div>
290+
)}
291+
244292
{error && (
245293
<Card urgency="red">
246294
<p className="text-urgency-red text-sm">{error}</p>
@@ -329,14 +377,24 @@ export function Disputes() {
329377
{disputes.map((d) => {
330378
const stage = normalizeStage(d);
331379
const stageIndex = DISPUTE_STAGES.indexOf(stage);
332-
const canAdvance = stage !== 'resolved';
333-
334380
return (
335381
<Card key={d.id} urgency={priorityUrgency(d.priority)}>
336382
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 sm:gap-4 mb-3">
337383
<div className="min-w-0 flex-1">
338384
<div className="flex items-center gap-2 mb-1 flex-wrap">
339-
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-700">
385+
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
386+
(() => {
387+
const severity = d.metadata?.triage_severity as string | undefined;
388+
if (severity === 'CRITICAL') return 'bg-red-100 text-red-700';
389+
if (severity === 'HIGH') return 'bg-orange-100 text-orange-700';
390+
if (severity === 'MEDIUM') return 'bg-yellow-100 text-yellow-700';
391+
if (severity === 'LOW') return 'bg-gray-100 text-gray-600';
392+
// Fallback to existing priority-based coloring
393+
if (d.priority <= 1) return 'bg-red-100 text-red-700';
394+
if (d.priority <= 3) return 'bg-orange-100 text-orange-700';
395+
return 'bg-gray-100 text-gray-600';
396+
})()
397+
}`}>
340398
P{d.priority}
341399
</span>
342400
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-card-muted">
@@ -348,6 +406,48 @@ export function Disputes() {
348406
<span className="text-xs px-2 py-0.5 rounded-full bg-card-hover text-card-muted uppercase">
349407
{d.status}
350408
</span>
409+
{d.metadata?.notion_task_id ? (
410+
d.metadata?.notion_url ? (
411+
<a
412+
href={d.metadata.notion_url as string}
413+
target="_blank"
414+
rel="noopener noreferrer"
415+
className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
416+
>
417+
Notion
418+
</a>
419+
) : (
420+
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700">
421+
Notion
422+
</span>
423+
)
424+
) : (
425+
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-400">
426+
Unlinked
427+
</span>
428+
)}
429+
{!!d.metadata?.triage_severity && (
430+
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
431+
(() => {
432+
const sev = d.metadata.triage_severity as string;
433+
if (sev === 'CRITICAL') return 'bg-red-100 text-red-700';
434+
if (sev === 'HIGH') return 'bg-orange-100 text-orange-700';
435+
if (sev === 'MEDIUM') return 'bg-yellow-100 text-yellow-700';
436+
return 'bg-gray-100 text-gray-600';
437+
})()
438+
}`}>
439+
{(d.metadata.triage_severity as string)}
440+
</span>
441+
)}
442+
{d.next_action_date && (() => {
443+
const days = daysUntil(d.next_action_date);
444+
const cls = countdownClasses(days);
445+
return (
446+
<span className={`text-xs px-2 py-0.5 rounded-full font-mono font-semibold ${cls.bg} ${cls.text}`}>
447+
{days < 0 ? 'OVERDUE' : countdownLabel(days)}
448+
</span>
449+
);
450+
})()}
351451
</div>
352452
<h2 className="text-base lg:text-lg font-semibold text-card-text">{d.title}</h2>
353453
<p className="text-card-muted text-sm">vs {d.counterparty}</p>
@@ -360,7 +460,32 @@ export function Disputes() {
360460
)}
361461
</div>
362462

363-
<ProgressDots completed={stageIndex + 1} total={DISPUTE_STAGES.length} className="mb-3" />
463+
<div className="flex items-center gap-1 mb-3 flex-wrap">
464+
{DISPUTE_STAGES.map((s, i) => {
465+
const isCurrent = s === stage;
466+
const isPast = i < stageIndex;
467+
const isNext = i === stageIndex + 1;
468+
return (
469+
<button
470+
key={s}
471+
disabled={!isNext || advancingId === d.id}
472+
onClick={() => isNext && advanceStage(d)}
473+
className={`text-xs px-2 py-0.5 rounded-full transition-all ${
474+
isCurrent
475+
? 'bg-chitty-500 text-white font-semibold'
476+
: isPast
477+
? 'bg-chitty-100 text-chitty-700'
478+
: isNext
479+
? 'bg-card-hover text-chitty-500 border border-chitty-300 cursor-pointer hover:bg-chitty-50'
480+
: 'bg-card-hover text-card-muted border border-card-border cursor-default'
481+
}`}
482+
title={isNext ? `Advance to ${STAGE_LABELS[s]}` : STAGE_LABELS[s]}
483+
>
484+
{STAGE_LABELS[s]}
485+
</button>
486+
);
487+
})}
488+
</div>
364489

365490
{d.description && (
366491
<p className="text-card-muted text-sm mb-3">{d.description}</p>
@@ -392,13 +517,6 @@ export function Disputes() {
392517
variant={expandedId === d.id && activePanel === 'deadlines' ? 'primary' : 'secondary'}
393518
onClick={() => togglePanel(d.id, 'deadlines')}
394519
/>
395-
{canAdvance && (
396-
<ActionButton
397-
label={advancingId === d.id ? 'Advancing...' : 'Advance Stage'}
398-
onClick={() => advanceStage(d)}
399-
loading={advancingId === d.id}
400-
/>
401-
)}
402520
</div>
403521

404522
{expandedId === d.id && activePanel === 'correspondence' && (
@@ -514,16 +632,46 @@ export function Disputes() {
514632
)}
515633
{deadlineList.length > 0 ? (
516634
<div className="space-y-2">
517-
{deadlineList.map((dl) => (
518-
<div key={dl.id} className="text-xs p-2 rounded-lg bg-card-bg border border-card-border">
519-
<div className="flex justify-between text-card-muted">
520-
<span>{dl.deadline_type}</span>
521-
<span>{formatDate(dl.deadline_date)}</span>
522-
</div>
523-
<p className="text-card-text mt-1 font-medium">{dl.title}</p>
524-
<p className="text-card-muted mt-1">{dl.case_ref}</p>
525-
</div>
526-
))}
635+
{[...deadlineList]
636+
.sort((a, b) => new Date(a.deadline_date).getTime() - new Date(b.deadline_date).getTime())
637+
.map((dl) => {
638+
const days = daysUntil(dl.deadline_date);
639+
const cls = countdownClasses(days);
640+
const isOverdue = days < 0;
641+
return (
642+
<div
643+
key={dl.id}
644+
className={`text-xs p-2 rounded-lg border ${
645+
isOverdue
646+
? 'bg-red-900/20 border-red-700'
647+
: days <= 3
648+
? 'bg-red-900/10 border-red-700/50'
649+
: days <= 7
650+
? 'bg-amber-900/10 border-amber-700/50'
651+
: 'bg-card-bg border-card-border'
652+
}`}
653+
>
654+
<div className="flex items-center justify-between">
655+
<div className="flex items-center gap-2">
656+
<span className="text-card-muted">{dl.deadline_type}</span>
657+
{isOverdue && (
658+
<span className="text-xs px-1.5 py-0.5 rounded font-semibold bg-red-600 text-white">
659+
OVERDUE
660+
</span>
661+
)}
662+
</div>
663+
<div className="flex items-center gap-2">
664+
<span className={`px-1.5 py-0.5 rounded font-mono font-semibold ${cls.bg} ${cls.text}`}>
665+
{countdownLabel(days)}
666+
</span>
667+
<span className="text-card-muted">{formatDate(dl.deadline_date)}</span>
668+
</div>
669+
</div>
670+
<p className="text-card-text mt-1 font-medium">{dl.title}</p>
671+
<p className="text-card-muted mt-1">{dl.case_ref}</p>
672+
</div>
673+
);
674+
})}
527675
</div>
528676
) : (
529677
<p className="text-card-muted text-xs">No linked deadlines for this dispute.</p>

0 commit comments

Comments
 (0)