feat: appove and go

This commit is contained in:
thanhnv
2026-07-19 09:37:16 +07:00
parent 13fae3e6c3
commit 709b6cccd6
24 changed files with 1245 additions and 70 deletions
@@ -99,19 +99,39 @@ export function Approvals() {
});
const decide = useMutation({
mutationFn: ({ id, decision }: { id: string; decision: 'approve' | 'reject' }) => {
mutationFn: async ({ proposal, decision }: { proposal: ApprovalProposal; decision: 'approve' | 'reject' }) => {
if (!actor) throw new Error('Authenticated session is unavailable.');
return api.decideApproval(actor, { id, decision, reason: decisionReason.trim() });
const response = await api.decideApproval(actor, { id: proposal.id, decision, reason: decisionReason.trim() });
if (decision !== 'approve' || proposal.action !== 'goal.workspace.execute') {
return { response, continuedGoalId: null };
}
const goalId = proposal.payload.goal_id;
if (typeof goalId !== 'string' || goalId.length === 0) {
throw new Error(`Request ${proposal.id} was approved, but its goal identifier is missing. Continue from Goal Orchestrator.`);
}
try {
await api.applyGoal({ ...actor, project: proposal.project }, goalId);
return { response, continuedGoalId: goalId };
} catch (error) {
throw new Error(`Request ${proposal.id} was approved, but apply/verification did not complete: ${errorMessage(error, 'unknown apply error')}`);
}
},
onSuccess: (response) => {
onSuccess: ({ response, continuedGoalId }) => {
const verb = response.proposal.status === 'approved' ? 'approved' : 'rejected';
setNotice({ tone: 'success', text: `Request ${response.proposal.id} was ${verb}.` });
const continuation = continuedGoalId ? ' The governed change was applied and verified.' : '';
setNotice({ tone: 'success', text: `Request ${response.proposal.id} was ${verb}.${continuation}` });
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
void queryClient.invalidateQueries({ queryKey: ['settings'] });
void queryClient.invalidateQueries({ queryKey: ['goals'] });
void queryClient.invalidateQueries({ queryKey: ['goal'] });
},
onError: (error) => setNotice({ tone: 'error', text: errorMessage(error, 'The approval decision could not be recorded.') }),
onError: (error) => {
setNotice({ tone: 'error', text: errorMessage(error, 'The approval decision could not be recorded.') });
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
void queryClient.invalidateQueries({ queryKey: ['goals'] });
void queryClient.invalidateQueries({ queryKey: ['goal'] });
},
});
if (session.isLoading || (actor && inbox.isLoading)) {
@@ -184,7 +204,7 @@ export function Approvals() {
<div className="mt-5 space-y-3">
{inbox.data.proposals.map((proposal) => {
const eligibility = reviewEligibility(actor, proposal);
const isCurrentDecision = decide.isPending && decide.variables?.id === proposal.id;
const isCurrentDecision = decide.isPending && decide.variables?.proposal.id === proposal.id;
return (
<article key={proposal.id} className="rounded-2xl border border-slate-200 bg-white p-4 transition hover:border-slate-300 hover:shadow-[0_12px_28px_rgba(15,23,42,0.055)] sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
@@ -211,8 +231,8 @@ export function Approvals() {
<span className={`text-xs font-medium ${eligibility.allowed ? 'text-emerald-700' : 'text-amber-700'}`}>{eligibility.reason}</span>
{eligibility.allowed && (
<div className="flex gap-2">
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ id: proposal.id, decision: 'reject' })} className="rounded-lg border border-rose-200 bg-white px-3.5 py-2 text-xs font-semibold text-rose-700 transition hover:bg-rose-50 focus:outline-none focus:ring-4 focus:ring-rose-100 disabled:cursor-not-allowed disabled:opacity-40">{isCurrentDecision && decide.variables?.decision === 'reject' ? 'Rejecting…' : 'Reject'}</button>
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ id: proposal.id, decision: 'approve' })} className="rounded-lg bg-emerald-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-4 focus:ring-emerald-100 disabled:cursor-not-allowed disabled:bg-slate-300">{isCurrentDecision && decide.variables?.decision === 'approve' ? 'Approving…' : 'Approve'}</button>
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ proposal, decision: 'reject' })} className="rounded-lg border border-rose-200 bg-white px-3.5 py-2 text-xs font-semibold text-rose-700 transition hover:bg-rose-50 focus:outline-none focus:ring-4 focus:ring-rose-100 disabled:cursor-not-allowed disabled:opacity-40">{isCurrentDecision && decide.variables?.decision === 'reject' ? 'Rejecting…' : 'Reject'}</button>
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ proposal, decision: 'approve' })} className="rounded-lg bg-emerald-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-4 focus:ring-emerald-100 disabled:cursor-not-allowed disabled:bg-slate-300">{isCurrentDecision && decide.variables?.decision === 'approve' ? 'Approving and continuing…' : proposal.action === 'goal.workspace.execute' ? 'Approve & continue' : 'Approve'}</button>
</div>
)}
</div>