Skip to content

Commit 89e113c

Browse files
os-zhuangclaude
andcommitted
feat(console): send back for revision in the approvals inbox (ADR-0044)
Console counterpart of framework #1769 (issue #1744): - approver action "Send back" (violet) with explanation dialog → POST /approvals/requests/:id/revise; auto-reject toast when the server reports the maxRevisions budget exceeded - submitter revision panel on a returned request: unlocked-record hint, edit-record link, optional comment, Resubmit (POST /resubmit, opens the next round) and Recall (abandon the revision window) - `returned` status: violet badge, status filter option, drawer strip - Round-N chips on the list row and drawer (row.round from the server) - timeline entries for revise (violet) / resubmit (blue) audit actions - approvalsApi.sendBack/resubmit + row typings (returned, round) - approvalsInbox i18n: 18 new keys across all ten locales (parity green) Browser-verified against the framework showcase stack: send back → record unlocks → resubmit → Round 2 chip → approve; third send-back auto-rejects. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 59ebc7b commit 89e113c

13 files changed

Lines changed: 384 additions & 3 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@object-ui/console": minor
3+
"@object-ui/i18n": minor
4+
---
5+
6+
ADR-0044 send-back-for-revision in the approvals inbox (framework #1744/#1769). Approvers get a "Send back" action (violet, with its own dialog) that ends the round as `returned` and unlocks the record; the submitter sees a revision panel on the returned request — edit-record link, optional comment, Resubmit (opens round N+1) and Recall (abandons the revision). New `returned` status badge/filter, Round-N chips (list + drawer), timeline rendering for `revise`/`resubmit` actions, `approvalsApi.sendBack/resubmit`, and ten-locale `approvalsInbox` strings.

apps/console/src/pages/system/ApprovalsInboxPage.tsx

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import {
9494
HelpCircle,
9595
Send,
9696
Check,
97+
CornerUpLeft,
9798
} from 'lucide-react';
9899
import {
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
*/
115116
const 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

122124
function 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>

apps/console/src/services/approvalsApi.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export interface ApprovalRequestRow {
2727
process_name: string;
2828
object_name: string;
2929
record_id: string;
30-
status: 'pending' | 'approved' | 'rejected' | 'recalled' | string;
30+
status: 'pending' | 'approved' | 'rejected' | 'recalled' | 'returned' | string;
3131
current_step?: string | null;
3232
current_step_index?: number | null;
3333
pending_approvers?: string[] | null;
@@ -55,6 +55,8 @@ export interface ApprovalRequestRow {
5555
sla_due_at?: string;
5656
/** Owning flow's approval steps for progress display (single reads only). */
5757
flow_steps?: Array<{ id: string; label: string; state: 'done' | 'current' | 'upcoming' }>;
58+
/** ADR-0044 revision round on this (run, node): absent/1 = first round. */
59+
round?: number;
5860
}
5961

6062
export interface ApprovalActionRow {
@@ -197,6 +199,32 @@ export const approvalsApi = {
197199
return { data: out.request };
198200
},
199201

202+
/**
203+
* Send back for revision (ADR-0044): the request finalizes `returned`, the
204+
* record unlocks, and the flow parks at a wait point until the submitter
205+
* resubmits. Past the node's `maxRevisions` budget the server auto-rejects
206+
* (`autoRejected: true`).
207+
*/
208+
async sendBack(id: string, body: { actor_id?: string; comment?: string }) {
209+
const out = await call<{ request: ApprovalRequestRow; resumed?: boolean; autoRejected?: boolean }>(
210+
`/approvals/requests/${encodeURIComponent(id)}/revise`,
211+
{ method: 'POST', body: JSON.stringify(body) },
212+
);
213+
return { data: out.request, autoRejected: out.autoRejected === true };
214+
},
215+
216+
/**
217+
* Resubmit a returned request after rework (ADR-0044, submitter only): the
218+
* flow re-enters the approval node and opens the next round's request.
219+
*/
220+
async resubmit(id: string, body: { actor_id?: string; comment?: string } = {}) {
221+
const out = await call<{ request: ApprovalRequestRow; resumed?: boolean }>(
222+
`/approvals/requests/${encodeURIComponent(id)}/resubmit`,
223+
{ method: 'POST', body: JSON.stringify(body) },
224+
);
225+
return { data: out.request, resumed: out.resumed === true };
226+
},
227+
200228
/** Free-form reply on the request thread (submitter or pending approver). */
201229
async comment(id: string, body: { actor_id?: string; comment: string }) {
202230
const out = await call<{ request: ApprovalRequestRow }>(

packages/i18n/src/locales/ar.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1812,6 +1812,24 @@ const ar = {
18121812
statusApproved: 'تمت الموافقة',
18131813
statusRejected: 'مرفوض',
18141814
statusRecalled: 'مسحوب',
1815+
statusReturned: 'أُعيد للمراجعة',
1816+
sendBackBtn: 'إعادة للمراجعة',
1817+
sendBackTitle: 'إعادة هذا الطلب للمراجعة؟',
1818+
sendBackBody: 'تنتهي هذه الجولة ويُفتح قفل السجل ليتمكن مقدم الطلب من تصحيح البيانات. عند إعادة الإرسال تبدأ جولة موافقة جديدة لجميع الموافقين.',
1819+
sendBackPlaceholder: 'ما الذي يجب تصحيحه قبل الموافقة؟',
1820+
sendBackSuccess: 'أُعيد للمراجعة — يمكن لمقدم الطلب الآن التعديل وإعادة الإرسال',
1821+
sendBackAutoRejected: 'تم بلوغ حد المراجعات — رُفض الطلب تلقائيًا',
1822+
actRevise: 'أُعيد للمراجعة',
1823+
actResubmit: 'أُعيد الإرسال',
1824+
roundChip: 'الجولة {{n}}',
1825+
returnedHint: 'أعاد أحد الموافقين هذا الطلب إليك. السجل غير مقفل — صحّح البيانات ثم أعد الإرسال لبدء جولة موافقة جديدة.',
1826+
resubmitBtn: 'إعادة الإرسال',
1827+
resubmitting: 'جارٍ إعادة الإرسال…',
1828+
resubmitSuccess: 'أُعيد الإرسال — بدأت جولة موافقة جديدة',
1829+
resubmitPlaceholder: 'ما الذي غيّرته؟',
1830+
editRecordBtn: 'تحرير السجل',
1831+
abandonTitle: 'التخلي عن هذه المراجعة؟',
1832+
abandonBody: 'يُسحب الطلب بدلاً من إعادة إرساله. تنتهي الموافقة هنا.',
18151833
emptyTitle: 'لا توجد طلبات',
18161834
emptyPending: 'كل شيء منجز — لا يوجد ما ينتظر موافقتك.',
18171835
emptyOther: 'لا يوجد شيء هنا بعد.',

0 commit comments

Comments
 (0)