@@ -2,8 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
22import { api , type Dispute , type Correspondence , type LegalDeadline } from '../lib/api' ;
33import { Card } from '../components/ui/Card' ;
44import { 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' ;
76import { useToast } from '../lib/toast' ;
87import { 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+
4659export 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" > ×</ 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