@@ -94,6 +94,7 @@ import {
9494 HelpCircle ,
9595 Send ,
9696 Check ,
97+ CornerUpLeft ,
9798} from 'lucide-react' ;
9899import {
99100 approvalsApi ,
@@ -109,14 +110,15 @@ const PAGE_SIZE = 50;
109110
110111/**
111112 * Semantic status colors (green = approved, amber = waiting, red = rejected,
112- * slate = recalled) — variant-based Badge colors read as monochrome chrome,
113- * not as state.
113+ * slate = recalled, violet = returned for revision ) — variant-based Badge
114+ * colors read as monochrome chrome, not as state.
114115 */
115116const STATUS_CLASSES : Record < string , string > = {
116117 pending : 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-400' ,
117118 approved : 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-400' ,
118119 rejected : 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400' ,
119120 recalled : 'border-border bg-muted text-muted-foreground' ,
121+ returned : 'border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-400' ,
120122} ;
121123
122124function formatDate ( s : string | null | undefined ) : string {
@@ -282,6 +284,7 @@ export function ApprovalsInboxPage() {
282284 case 'approved' : return tr ( 'statusApproved' , 'Approved' ) ;
283285 case 'rejected' : return tr ( 'statusRejected' , 'Rejected' ) ;
284286 case 'recalled' : return tr ( 'statusRecalled' , 'Recalled' ) ;
287+ case 'returned' : return tr ( 'statusReturned' , 'Returned for revision' ) ;
285288 default : return status ;
286289 }
287290 } , [ tr ] ) ;
@@ -355,6 +358,10 @@ export function ApprovalsInboxPage() {
355358 const [ reassignTo , setReassignTo ] = useState ( '' ) ;
356359 const [ requestInfoOpen , setRequestInfoOpen ] = useState ( false ) ;
357360 const [ requestInfoText , setRequestInfoText ] = useState ( '' ) ;
361+ // Send back for revision (ADR-0044) — a flow movement, unlike request-info.
362+ const [ sendBackOpen , setSendBackOpen ] = useState ( false ) ;
363+ const [ sendBackText , setSendBackText ] = useState ( '' ) ;
364+ const [ resubmitting , setResubmitting ] = useState ( false ) ;
358365 const [ reply , setReply ] = useState ( '' ) ;
359366 const [ threadBusy , setThreadBusy ] = useState ( false ) ;
360367 const [ userOptions , setUserOptions ] = useState < Array < { name : string ; email : string } > > ( [ ] ) ;
@@ -608,6 +615,54 @@ export function ApprovalsInboxPage() {
608615 }
609616 } , [ selected , requestInfoText , resolveActor , refreshThread , humanizeError , tr ] ) ;
610617
618+ /**
619+ * Send back for revision (ADR-0044): finalizes this round as `returned`,
620+ * unlocks the record, and parks the flow until the submitter resubmits.
621+ * Past the node's revision budget the server auto-rejects instead.
622+ */
623+ const doSendBack = useCallback ( async ( ) => {
624+ if ( ! selected ) return ;
625+ setThreadBusy ( true ) ;
626+ try {
627+ const res = await approvalsApi . sendBack ( selected . id , {
628+ actor_id : resolveActor ( selected ) , comment : sendBackText . trim ( ) || undefined ,
629+ } ) ;
630+ toast . success ( res . autoRejected
631+ ? tr ( 'sendBackAutoRejected' , 'Revision limit reached — the request was auto-rejected' )
632+ : tr ( 'sendBackSuccess' , 'Sent back for revision — the requester can now edit and resubmit' ) ) ;
633+ setSendBackOpen ( false ) ;
634+ setSendBackText ( '' ) ;
635+ await refreshThread ( selected . id ) ;
636+ refreshBadge ( ) ;
637+ } catch ( err : any ) {
638+ toast . error ( humanizeError ( err , tr ( 'actionFailed' , 'Action failed' ) ) ) ;
639+ } finally {
640+ setThreadBusy ( false ) ;
641+ }
642+ } , [ selected , sendBackText , resolveActor , refreshThread , refreshBadge , humanizeError , tr ] ) ;
643+
644+ /**
645+ * Resubmit after rework (ADR-0044, submitter): the flow re-enters the
646+ * approval node and opens the next round's request.
647+ */
648+ const doResubmit = useCallback ( async ( ) => {
649+ if ( ! selected ) return ;
650+ setResubmitting ( true ) ;
651+ try {
652+ await approvalsApi . resubmit ( selected . id , {
653+ actor_id : user ?. id , comment : comment . trim ( ) || undefined ,
654+ } ) ;
655+ toast . success ( tr ( 'resubmitSuccess' , 'Resubmitted — a new approval round has opened' ) ) ;
656+ setComment ( '' ) ;
657+ await refreshThread ( selected . id ) ;
658+ refreshBadge ( ) ;
659+ } catch ( err : any ) {
660+ toast . error ( humanizeError ( err , tr ( 'actionFailed' , 'Action failed' ) ) ) ;
661+ } finally {
662+ setResubmitting ( false ) ;
663+ }
664+ } , [ selected , comment , user ?. id , refreshThread , refreshBadge , humanizeError , tr ] ) ;
665+
611666 const doReply = useCallback ( async ( ) => {
612667 if ( ! selected || ! reply . trim ( ) ) return ;
613668 setThreadBusy ( true ) ;
@@ -646,6 +701,12 @@ export function ApprovalsInboxPage() {
646701 return selected . submitter_id === user ?. id || actorOverride . trim ( ) . length > 0 ;
647702 } , [ selected , user ?. id , actorOverride ] ) ;
648703
704+ /** ADR-0044: the submitter may resubmit (or abandon) a returned request. */
705+ const canResubmit = useMemo ( ( ) => {
706+ if ( ! selected || selected . status !== 'returned' ) return false ;
707+ return selected . submitter_id === user ?. id || actorOverride . trim ( ) . length > 0 ;
708+ } , [ selected , user ?. id , actorOverride ] ) ;
709+
649710
650711 /** Unique process labels present in current rows (for filter dropdown). */
651712 const processOptions = useMemo ( ( ) => {
@@ -898,6 +959,11 @@ export function ApprovalsInboxPage() {
898959 < div className = "font-medium truncate" > { processLabel ( r ) } </ div >
899960 < div className = "text-xs text-muted-foreground truncate" >
900961 { stepLabel ( r ) || '—' }
962+ { ( r . round ?? 1 ) > 1 && (
963+ < span className = "ml-1.5 text-violet-600 dark:text-violet-400" >
964+ { tr ( 'roundChip' , 'Round {{n}}' , { n : r . round } ) }
965+ </ span >
966+ ) }
901967 </ div >
902968 </ div >
903969 ) ;
@@ -1023,6 +1089,7 @@ export function ApprovalsInboxPage() {
10231089 < SelectItem value = "approved" > { statusLabel ( 'approved' ) } </ SelectItem >
10241090 < SelectItem value = "rejected" > { statusLabel ( 'rejected' ) } </ SelectItem >
10251091 < SelectItem value = "recalled" > { statusLabel ( 'recalled' ) } </ SelectItem >
1092+ < SelectItem value = "returned" > { statusLabel ( 'returned' ) } </ SelectItem >
10261093 </ SelectContent >
10271094 </ Select >
10281095 ) }
@@ -1364,6 +1431,30 @@ export function ApprovalsInboxPage() {
13641431 </ AlertDialogContent >
13651432 </ AlertDialog >
13661433
1434+ { /* Send back for revision dialog (ADR-0044) */ }
1435+ < AlertDialog open = { sendBackOpen } onOpenChange = { ( open ) => { if ( ! open ) { setSendBackOpen ( false ) ; setSendBackText ( '' ) ; } } } >
1436+ < AlertDialogContent >
1437+ < AlertDialogHeader >
1438+ < AlertDialogTitle > { tr ( 'sendBackTitle' , 'Send this request back for revision?' ) } </ AlertDialogTitle >
1439+ < AlertDialogDescription >
1440+ { tr ( 'sendBackBody' , 'This round ends and the record unlocks so the requester can fix the data. When they resubmit, a fresh approval round opens for all approvers.' ) }
1441+ </ AlertDialogDescription >
1442+ </ AlertDialogHeader >
1443+ < Textarea
1444+ value = { sendBackText }
1445+ onChange = { ( e ) => setSendBackText ( e . target . value ) }
1446+ rows = { 3 }
1447+ placeholder = { tr ( 'sendBackPlaceholder' , 'What needs to be fixed before this can be approved?' ) }
1448+ />
1449+ < AlertDialogFooter >
1450+ < AlertDialogCancel > { tr ( 'cancel' , 'Cancel' ) } </ AlertDialogCancel >
1451+ < AlertDialogAction disabled = { threadBusy } onClick = { ( ) => void doSendBack ( ) } >
1452+ { tr ( 'sendBackBtn' , 'Send back' ) }
1453+ </ AlertDialogAction >
1454+ </ AlertDialogFooter >
1455+ </ AlertDialogContent >
1456+ </ AlertDialog >
1457+
13671458 { /* Shared inline-reject confirmation */ }
13681459 < AlertDialog open = { ! ! rejectTarget } onOpenChange = { ( open ) => ! open && setRejectTarget ( null ) } >
13691460 < AlertDialogContent >
@@ -1433,6 +1524,11 @@ export function ApprovalsInboxPage() {
14331524 { /* Status strip */ }
14341525 < div className = "flex flex-wrap items-center gap-2 text-xs text-muted-foreground" >
14351526 < StatusBadge status = { selected . status } />
1527+ { ( selected . round ?? 1 ) > 1 && (
1528+ < Badge variant = "outline" className = "text-[10px] border-violet-200 text-violet-700 dark:border-violet-500/30 dark:text-violet-400" >
1529+ { tr ( 'roundChip' , 'Round {{n}}' , { n : selected . round } ) }
1530+ </ Badge >
1531+ ) }
14361532 < span className = "inline-flex items-center gap-1" title = { formatDate ( submittedAt ( selected ) ) } >
14371533 < Clock className = "h-3 w-3" />
14381534 { tr ( 'submittedAgo' , 'Submitted {{when}}' , { when : formatRelative ( submittedAt ( selected ) ) } ) }
@@ -1541,6 +1637,8 @@ export function ApprovalsInboxPage() {
15411637 : a . action === 'request_info' ? 'bg-amber-500'
15421638 : a . action === 'comment' ? 'bg-slate-400'
15431639 : a . action === 'escalate' ? 'bg-red-500'
1640+ : a . action === 'revise' ? 'bg-violet-500'
1641+ : a . action === 'resubmit' ? 'bg-blue-500'
15441642 : 'bg-muted-foreground' ;
15451643 const actorName = a . actor_id === 'system:sla'
15461644 ? tr ( 'systemSlaActor' , 'System (SLA)' )
@@ -1557,6 +1655,8 @@ export function ApprovalsInboxPage() {
15571655 : a . action === 'request_info' ? tr ( 'actRequestInfo' , 'Requested more info' )
15581656 : a . action === 'comment' ? tr ( 'actComment' , 'Commented' )
15591657 : a . action === 'escalate' ? tr ( 'actEscalate' , 'SLA escalated' )
1658+ : a . action === 'revise' ? tr ( 'actRevise' , 'Sent back for revision' )
1659+ : a . action === 'resubmit' ? tr ( 'actResubmit' , 'Resubmitted' )
15601660 : a . action ;
15611661 return (
15621662 < li key = { a . id } className = "relative text-xs" >
@@ -1724,6 +1824,14 @@ export function ApprovalsInboxPage() {
17241824 </ AlertDialog >
17251825 { canApproveReject && (
17261826 < >
1827+ < Button
1828+ size = "sm" variant = "outline" disabled = { submitting !== null || threadBusy }
1829+ className = "border-violet-300 text-violet-700 hover:bg-violet-50 dark:text-violet-400"
1830+ onClick = { ( ) => setSendBackOpen ( true ) }
1831+ >
1832+ < CornerUpLeft className = "h-4 w-4 mr-1" />
1833+ { tr ( 'sendBackBtn' , 'Send back' ) }
1834+ </ Button >
17271835 < Button
17281836 size = "sm" variant = "outline" disabled = { submitting !== null || threadBusy }
17291837 className = "border-amber-300 text-amber-700 hover:bg-amber-50 dark:text-amber-400"
@@ -1784,6 +1892,65 @@ export function ApprovalsInboxPage() {
17841892 </ div >
17851893 </ >
17861894 ) }
1895+
1896+ { /* ADR-0044 revision window: the request came back to the
1897+ submitter — the record is unlocked for rework; resubmitting
1898+ opens the next approval round, recalling abandons it. */ }
1899+ { canResubmit && (
1900+ < >
1901+ < Separator />
1902+ < div className = "space-y-3" >
1903+ < div className = "text-xs text-muted-foreground" >
1904+ { tr ( 'returnedHint' , 'An approver sent this back to you. The record is unlocked — fix the data, then resubmit to start a new approval round.' ) }
1905+ </ div >
1906+ < div >
1907+ < Label htmlFor = "resubmit-comment" className = "text-xs" > { tr ( 'comment' , 'Comment (optional)' ) } </ Label >
1908+ < Textarea
1909+ id = "resubmit-comment"
1910+ value = { comment }
1911+ onChange = { ( e ) => setComment ( e . target . value ) }
1912+ rows = { 2 }
1913+ className = "mt-1"
1914+ placeholder = { tr ( 'resubmitPlaceholder' , 'What did you change?' ) }
1915+ />
1916+ </ div >
1917+ < div className = "flex gap-2 flex-wrap" >
1918+ < Button asChild size = "sm" variant = "outline" >
1919+ < Link to = { recordHref ( selected ) } >
1920+ < ExternalLink className = "h-4 w-4 mr-1" />
1921+ { tr ( 'editRecordBtn' , 'Edit record' ) }
1922+ </ Link >
1923+ </ Button >
1924+ < Button size = "sm" disabled = { resubmitting } onClick = { ( ) => void doResubmit ( ) } >
1925+ < RefreshCw className = { cn ( 'h-4 w-4 mr-1' , resubmitting && 'animate-spin' ) } />
1926+ { resubmitting ? tr ( 'resubmitting' , 'Resubmitting…' ) : tr ( 'resubmitBtn' , 'Resubmit' ) }
1927+ </ Button >
1928+ < AlertDialog >
1929+ < AlertDialogTrigger asChild >
1930+ < Button size = "sm" variant = "outline" disabled = { resubmitting } >
1931+ < Undo2 className = "h-4 w-4 mr-1" />
1932+ { tr ( 'recall' , 'Recall' ) }
1933+ </ Button >
1934+ </ AlertDialogTrigger >
1935+ < AlertDialogContent >
1936+ < AlertDialogHeader >
1937+ < AlertDialogTitle > { tr ( 'abandonTitle' , 'Abandon this revision?' ) } </ AlertDialogTitle >
1938+ < AlertDialogDescription >
1939+ { tr ( 'abandonBody' , 'This withdraws the request instead of resubmitting it. The approval ends here.' ) }
1940+ </ AlertDialogDescription >
1941+ </ AlertDialogHeader >
1942+ < AlertDialogFooter >
1943+ < AlertDialogCancel > { tr ( 'cancel' , 'Cancel' ) } </ AlertDialogCancel >
1944+ < AlertDialogAction onClick = { ( ) => doAction ( 'recall' ) } >
1945+ { tr ( 'recall' , 'Recall' ) }
1946+ </ AlertDialogAction >
1947+ </ AlertDialogFooter >
1948+ </ AlertDialogContent >
1949+ </ AlertDialog >
1950+ </ div >
1951+ </ div >
1952+ </ >
1953+ ) }
17871954 </ div >
17881955 ) : null }
17891956 </ SheetContent >
0 commit comments