Approval workflow frontend: bell + StageApprovals + /approvals/:id

Brings the approval flow into the UI end-to-end.

Nav:
- NotificationBell with live unread-count badge (polled every 60s).
  Click to open a popover listing the latest 50 notifications;
  unread items are tinted with the OLIVER accent. Clicking an item
  marks it read and routes to its link_path. "Mark all read" empties
  the unread state in one call.

Gated stages (3 = qualification, 14 = approval gates):
- StageApprovals component embedded in those stage views. Empty state
  prompts the user to request an approval. Form: pick role + approver
  from the directory (loaded from /api/users — populated automatically
  as people log in). Submitting fires the backend, which creates the
  approval, queues the in-app notification and the Mailgun email
  (skipped silently in dev). Existing approvals render as a list with
  status badges, requested-at + decided-at timestamps, comment text,
  and a deeplink to the approval page. A summary line at the bottom
  tells the user when all approvals are in (and whether they all
  approved or any rejected).

Approval page:
- New routes /approvals/:id and /approvals/by-token/:token (the email
  link path) hit the same component. Shows opportunity context (client,
  region, deadline, summary), the approval's status with timestamps,
  prior decision notes if any, and — for the assigned approver or any
  admin — an Approve / Reject form with a notes textarea. Reject is
  disabled until notes are filled in (gentle nudge to give a reason).
  After submitting, TanStack Query invalidations refresh the bell, the
  stage approvals list, and the opportunity stages so the user sees
  the update without a manual reload.

Wiring:
- Three new TanStack Query modules: api/approvals.ts (stage list, mine,
  by id, by token, request, decide), api/notifications.ts (list,
  unread-count w/ refetch interval, mark read, mark all), api/users.ts
  (directory + me).
- types/index.ts gains Approval, Notification, UserBrief, APPROVAL_ROLES.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-27 13:32:05 -04:00
parent 3f7531a1bf
commit f631dad00b
9 changed files with 823 additions and 1 deletions

View file

@ -2,7 +2,9 @@ import { Routes, Route, Link } from 'react-router-dom'
import Dashboard from './pages/Dashboard'
import NewOpportunity from './pages/NewOpportunity'
import OpportunityView from './pages/OpportunityView'
import ApprovalView from './pages/ApprovalView'
import About from './pages/About'
import NotificationBell from './components/NotificationBell'
import './App.css'
export default function App() {
@ -15,6 +17,7 @@ export default function App() {
</Link>
<Link to="/about" className="nav-link">How it works</Link>
<span className="brand-tag">v0.1 · Phase 1</span>
<NotificationBell />
</nav>
<main className="app-main">
<Routes>
@ -22,6 +25,8 @@ export default function App() {
<Route path="/opportunities/new" element={<NewOpportunity />} />
<Route path="/opportunities/:id" element={<OpportunityView />} />
<Route path="/opportunities/:id/stage/:stageNumber" element={<OpportunityView />} />
<Route path="/approvals/:id" element={<ApprovalView />} />
<Route path="/approvals/by-token/:token" element={<ApprovalView />} />
<Route path="/about" element={<About />} />
</Routes>
</main>

View file

@ -0,0 +1,98 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from './client';
import { Approval, ApprovalContext } from '../types';
import { opportunitiesKeys } from './opportunities';
export const approvalsKeys = {
all: ['approvals'] as const,
forStage: (oppId: number, stage: number) => [...approvalsKeys.all, 'stage', oppId, stage] as const,
detail: (id: number) => [...approvalsKeys.all, 'detail', id] as const,
byToken: (token: string) => [...approvalsKeys.all, 'token', token] as const,
mine: () => [...approvalsKeys.all, 'mine'] as const,
};
export function useStageApprovals(opportunityId: number | undefined, stageNumber: number) {
return useQuery({
queryKey: approvalsKeys.forStage(opportunityId ?? 0, stageNumber),
queryFn: async (): Promise<Approval[]> => {
const res = await api.get(`/opportunities/${opportunityId}/stages/${stageNumber}/approvals`);
return res.data;
},
enabled: opportunityId !== undefined && opportunityId > 0,
});
}
export function useMyApprovals() {
return useQuery({
queryKey: approvalsKeys.mine(),
queryFn: async (): Promise<Approval[]> => {
const res = await api.get('/approvals/me');
return res.data;
},
});
}
export function useApprovalContext(approvalId: number | undefined) {
return useQuery({
queryKey: approvalsKeys.detail(approvalId ?? 0),
queryFn: async (): Promise<ApprovalContext> => {
const res = await api.get(`/approvals/${approvalId}`);
return res.data;
},
enabled: approvalId !== undefined && approvalId > 0,
});
}
export function useApprovalByToken(token: string | undefined) {
return useQuery({
queryKey: approvalsKeys.byToken(token ?? ''),
queryFn: async (): Promise<ApprovalContext> => {
const res = await api.get(`/approvals/by-token/${token}`);
return res.data;
},
enabled: !!token,
});
}
export function useRequestApprovals(opportunityId: number, stageNumber: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (
requests: { role_required: string; approver_user_id: number }[],
): Promise<Approval[]> => {
const res = await api.post(
`/opportunities/${opportunityId}/stages/${stageNumber}/approvals`,
{ requests },
);
return res.data;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: approvalsKeys.forStage(opportunityId, stageNumber) });
qc.invalidateQueries({ queryKey: approvalsKeys.mine() });
},
});
}
export function useDecideApproval() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (params: {
approvalId: number;
decision: 'approve' | 'reject';
comment: string | null;
}): Promise<Approval> => {
const res = await api.post(`/approvals/${params.approvalId}/decision`, {
decision: params.decision,
comment: params.comment,
});
return res.data;
},
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: approvalsKeys.detail(data.id) });
qc.invalidateQueries({ queryKey: approvalsKeys.forStage(data.opportunity_id, data.stage_number) });
qc.invalidateQueries({ queryKey: approvalsKeys.mine() });
qc.invalidateQueries({ queryKey: opportunitiesKeys.detail(data.opportunity_id) });
qc.invalidateQueries({ queryKey: opportunitiesKeys.stages(data.opportunity_id) });
},
});
}

View file

@ -0,0 +1,56 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from './client';
import { Notification } from '../types';
export const notificationsKeys = {
all: ['notifications'] as const,
list: (unreadOnly: boolean) => [...notificationsKeys.all, 'list', unreadOnly] as const,
unreadCount: () => [...notificationsKeys.all, 'unread-count'] as const,
};
export function useNotifications(unreadOnly = false) {
return useQuery({
queryKey: notificationsKeys.list(unreadOnly),
queryFn: async (): Promise<Notification[]> => {
const res = await api.get('/notifications/me', {
params: { unread_only: unreadOnly },
});
return res.data;
},
});
}
export function useUnreadCount(refetchSeconds = 60) {
return useQuery({
queryKey: notificationsKeys.unreadCount(),
queryFn: async (): Promise<number> => {
const res = await api.get('/notifications/me/unread-count');
return res.data.count ?? 0;
},
refetchInterval: refetchSeconds * 1000,
});
}
export function useMarkRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await api.put(`/notifications/${id}/read`);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: notificationsKeys.all });
},
});
}
export function useMarkAllRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
await api.post('/notifications/me/mark-all-read');
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: notificationsKeys.all });
},
});
}

23
frontend/src/api/users.ts Normal file
View file

@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import api from './client';
import { UserBrief } from '../types';
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async (): Promise<UserBrief[]> => {
const res = await api.get('/users');
return res.data;
},
});
}
export function useMe() {
return useQuery({
queryKey: ['users', 'me'],
queryFn: async (): Promise<UserBrief> => {
const res = await api.get('/users/me');
return res.data;
},
});
}

View file

@ -0,0 +1,138 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNotifications, useUnreadCount, useMarkRead, useMarkAllRead } from '../api/notifications';
import { Notification } from '../types';
export default function NotificationBell() {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const ref = useRef<HTMLDivElement | null>(null);
const { data: count } = useUnreadCount();
const { data: notifications, isLoading } = useNotifications();
const markRead = useMarkRead();
const markAllRead = useMarkAllRead();
useEffect(() => {
function onClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
if (open) {
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}
}, [open]);
function handleClick(n: Notification) {
if (!n.read) markRead.mutate(n.id);
setOpen(false);
if (n.link_path) navigate(n.link_path);
}
const unread = count ?? 0;
return (
<div ref={ref} style={wrapStyle}>
<button
onClick={() => setOpen(!open)}
aria-label="Notifications"
style={bellBtnStyle}
>
<span style={bellIconStyle}>🔔</span>
{unread > 0 && <span style={dotStyle}>{unread > 9 ? '9+' : unread}</span>}
</button>
{open && (
<div style={popoverStyle}>
<div style={popoverHeaderStyle}>
<span style={{ fontWeight: 600, fontSize: 13 }}>Notifications</span>
{unread > 0 && (
<button onClick={() => markAllRead.mutate()} style={linkBtnStyle}>
Mark all read
</button>
)}
</div>
{isLoading && <div style={emptyStyle}>Loading</div>}
{!isLoading && (notifications?.length ?? 0) === 0 && (
<div style={emptyStyle}>No notifications.</div>
)}
{!isLoading && notifications && notifications.length > 0 && (
<ul style={listStyle}>
{notifications.map((n) => (
<li key={n.id} style={n.read ? itemStyle : itemUnreadStyle} onClick={() => handleClick(n)}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<strong style={{ fontSize: 13 }}>{n.title}</strong>
{!n.read && <span style={unreadDotStyle} aria-label="unread" />}
</div>
{n.body && <div style={bodyStyle}>{n.body}</div>}
<div style={timeStyle}>{new Date(n.created_at).toLocaleString()}</div>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
const wrapStyle: React.CSSProperties = { position: 'relative', marginLeft: 12 };
const bellBtnStyle: React.CSSProperties = {
background: 'transparent', border: '1px solid var(--color-border)',
borderRadius: 8, padding: '6px 10px', cursor: 'pointer',
color: 'var(--color-text)', position: 'relative', display: 'inline-flex',
alignItems: 'center', gap: 4,
};
const bellIconStyle: React.CSSProperties = {
fontSize: 14, filter: 'grayscale(0.4)',
};
const dotStyle: React.CSSProperties = {
background: '#ef4444', color: '#fff', fontSize: 10, fontWeight: 700,
padding: '1px 6px', borderRadius: 8, lineHeight: 1.2, marginLeft: 4,
};
const popoverStyle: React.CSSProperties = {
position: 'absolute', top: 'calc(100% + 8px)', right: 0,
width: 360, maxHeight: 480, overflowY: 'auto',
background: 'var(--color-bg-card)', border: '1px solid var(--color-border)',
borderRadius: 10, boxShadow: '0 12px 28px rgba(0,0,0,0.35)',
zIndex: 50,
};
const popoverHeaderStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 14px', borderBottom: '1px solid var(--color-border)',
};
const linkBtnStyle: React.CSSProperties = {
background: 'none', border: 'none', color: 'var(--color-accent)',
cursor: 'pointer', fontSize: 12, fontWeight: 600, padding: 0,
};
const emptyStyle: React.CSSProperties = {
padding: 24, color: 'var(--color-text-muted)', fontSize: 13, textAlign: 'center',
};
const listStyle: React.CSSProperties = { listStyle: 'none', padding: 0, margin: 0 };
const itemStyle: React.CSSProperties = {
padding: '12px 14px', borderBottom: '1px solid var(--color-border-light)',
cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 4,
};
const itemUnreadStyle: React.CSSProperties = { ...itemStyle, background: 'rgba(255,196,7,0.04)' };
const unreadDotStyle: React.CSSProperties = {
width: 8, height: 8, borderRadius: '50%', background: '#FFC407',
display: 'inline-block', flexShrink: 0, marginTop: 4,
};
const bodyStyle: React.CSSProperties = {
color: 'var(--color-text-secondary)', fontSize: 12, lineHeight: 1.5,
};
const timeStyle: React.CSSProperties = {
color: 'var(--color-text-muted)', fontSize: 11, marginTop: 2,
};

View file

@ -0,0 +1,222 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useStageApprovals, useRequestApprovals } from '../api/approvals';
import { useUsers } from '../api/users';
import { APPROVAL_ROLES, ApprovalRole } from '../types';
interface Props {
opportunityId: number;
stageNumber: number;
canEdit: boolean;
}
const STATUS_BADGE: Record<string, { bg: string; fg: string }> = {
pending: { bg: 'rgba(255,196,7,0.12)', fg: '#FFC407' },
approved: { bg: 'rgba(46,125,50,0.14)', fg: '#86efac' },
rejected: { bg: 'rgba(239,68,68,0.14)', fg: '#fca5a5' },
};
export default function StageApprovals({ opportunityId, stageNumber, canEdit }: Props) {
const { data: approvals, isLoading } = useStageApprovals(opportunityId, stageNumber);
const { data: users } = useUsers();
const request = useRequestApprovals(opportunityId, stageNumber);
const [showForm, setShowForm] = useState(false);
const [pickedRole, setPickedRole] = useState<ApprovalRole>('commercial');
const [pickedUser, setPickedUser] = useState<number | ''>('');
const [error, setError] = useState<string | null>(null);
async function submit() {
if (!pickedUser) return;
setError(null);
try {
await request.mutateAsync([{ role_required: pickedRole, approver_user_id: pickedUser }]);
setShowForm(false);
setPickedUser('');
} catch (err: any) {
setError(err?.response?.data?.detail ?? err?.message ?? 'Failed to request approval');
}
}
const allDecided = approvals && approvals.length > 0 && approvals.every((a) => a.status !== 'pending');
const allApproved = approvals && approvals.length > 0 && approvals.every((a) => a.status === 'approved');
return (
<div style={containerStyle}>
<div style={headerStyle}>
<div>
<h3 style={titleStyle}>Approvals</h3>
<p style={hintStyle}>
This is a gated stage. Stage {stageNumber} can only complete once every approval below is in <strong>approved</strong> state.
</p>
</div>
{canEdit && !showForm && (
<button onClick={() => setShowForm(true)} style={primaryBtnStyle}>+ Request approval</button>
)}
</div>
{isLoading && <div style={emptyStyle}>Loading approvals</div>}
{!isLoading && (!approvals || approvals.length === 0) && !showForm && (
<div style={emptyStyle}>
No approvals requested yet. Click <strong>+ Request approval</strong> to send one.
</div>
)}
{showForm && (
<div style={formStyle}>
<div style={twoColStyle}>
<label style={labelStyle}>
<span style={labelTextStyle}>Approver role</span>
<select value={pickedRole} onChange={(e) => setPickedRole(e.target.value as ApprovalRole)} style={inputStyle}>
{APPROVAL_ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</label>
<label style={labelStyle}>
<span style={labelTextStyle}>Approver</span>
<select
value={pickedUser}
onChange={(e) => setPickedUser(e.target.value ? parseInt(e.target.value, 10) : '')}
style={inputStyle}
>
<option value="">Pick a user</option>
{(users ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.name ? `${u.name} (${u.email})` : u.email}
</option>
))}
</select>
</label>
</div>
{error && <div style={errorStyle}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button onClick={submit} disabled={!pickedUser || request.isPending} style={primaryBtnStyle}>
{request.isPending ? 'Sending…' : 'Send approval request'}
</button>
<button onClick={() => { setShowForm(false); setError(null); }} style={secondaryBtnStyle}>
Cancel
</button>
</div>
<p style={{ marginTop: 8, color: 'var(--color-text-muted)', fontSize: 11 }}>
An email is sent via Mailgun with a deeplink to the approval page (skipped silently if Mailgun isn't configured).
</p>
</div>
)}
{approvals && approvals.length > 0 && (
<ul style={listStyle}>
{approvals.map((a) => {
const badge = STATUS_BADGE[a.status] ?? STATUS_BADGE.pending;
return (
<li key={a.id} style={itemStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>
{a.role_required}
<span style={{ color: 'var(--color-text-muted)', fontWeight: 400, marginLeft: 8 }}>
{a.approver?.name || a.approver?.email || `user #${a.approver_user_id}`}
</span>
</div>
<div style={subTextStyle}>
Requested {new Date(a.requested_at).toLocaleString()}
{a.email_sent_at && <span> · email sent</span>}
{a.decided_at && <span> · decided {new Date(a.decided_at).toLocaleString()}</span>}
</div>
{a.comment && <div style={commentStyle}>"{a.comment}"</div>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
<span style={{ ...badgeStyle, background: badge.bg, color: badge.fg }}>{a.status.toUpperCase()}</span>
<Link to={`/approvals/${a.id}`} style={linkStyle}>Open </Link>
</div>
</div>
</li>
);
})}
</ul>
)}
{allDecided && (
<div style={{ ...summaryStyle, color: allApproved ? '#86efac' : '#fca5a5' }}>
{allApproved
? 'All approvals received — you can complete this stage.'
: 'One or more approvals were rejected — address the reasons before completing.'}
</div>
)}
</div>
);
}
const containerStyle: React.CSSProperties = {
background: 'var(--color-bg-card)', border: '1px solid var(--color-border)',
borderRadius: 10, padding: 18, marginTop: 18,
};
const headerStyle: React.CSSProperties = {
display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16,
};
const titleStyle: React.CSSProperties = { margin: 0, fontSize: 14, fontWeight: 600 };
const hintStyle: React.CSSProperties = {
margin: '4px 0 0 0', color: 'var(--color-text-muted)', fontSize: 12,
};
const emptyStyle: React.CSSProperties = {
padding: 16, marginTop: 12, color: 'var(--color-text-muted)', fontSize: 13,
background: 'var(--color-bg-input)', border: '1px dashed var(--color-border)',
borderRadius: 8, textAlign: 'center',
};
const formStyle: React.CSSProperties = {
marginTop: 12, padding: 16,
background: 'var(--color-bg-input)', border: '1px solid var(--color-border)',
borderRadius: 8,
};
const twoColStyle: React.CSSProperties = { display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 12 };
const labelStyle: React.CSSProperties = { display: 'block' };
const labelTextStyle: React.CSSProperties = {
display: 'block', fontSize: 11, color: 'var(--color-text-secondary)',
textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4,
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '8px 10px', background: 'var(--color-bg)',
border: '1px solid var(--color-border)', borderRadius: 6,
color: 'var(--color-text)', fontSize: 13,
};
const primaryBtnStyle: React.CSSProperties = {
background: '#FFC407', color: '#0e0f13', border: 'none',
padding: '8px 14px', borderRadius: 6, fontWeight: 600, fontSize: 12, cursor: 'pointer',
};
const secondaryBtnStyle: React.CSSProperties = {
background: 'transparent', color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border)', padding: '8px 14px', borderRadius: 6,
fontWeight: 500, fontSize: 12, cursor: 'pointer',
};
const errorStyle: React.CSSProperties = {
marginTop: 10, background: 'rgba(239,68,68,0.10)',
border: '1px solid rgba(239,68,68,0.3)', color: '#fca5a5',
padding: '8px 10px', borderRadius: 6, fontSize: 12,
};
const listStyle: React.CSSProperties = {
listStyle: 'none', padding: 0, margin: '14px 0 0',
display: 'flex', flexDirection: 'column', gap: 8,
};
const itemStyle: React.CSSProperties = {
padding: 12, background: 'var(--color-bg-input)',
border: '1px solid var(--color-border-light)', borderRadius: 8,
};
const subTextStyle: React.CSSProperties = {
color: 'var(--color-text-muted)', fontSize: 11, marginTop: 4,
};
const commentStyle: React.CSSProperties = {
marginTop: 6, fontSize: 12, fontStyle: 'italic',
color: 'var(--color-text-secondary)', borderLeft: '3px solid var(--color-border)',
paddingLeft: 10,
};
const badgeStyle: React.CSSProperties = {
fontSize: 10, fontWeight: 700, letterSpacing: '0.05em',
padding: '3px 8px', borderRadius: 12,
};
const linkStyle: React.CSSProperties = {
color: 'var(--color-accent)', fontSize: 11, fontWeight: 600,
};
const summaryStyle: React.CSSProperties = {
marginTop: 14, padding: '10px 14px', borderRadius: 8,
background: 'var(--color-bg-input)', border: '1px solid var(--color-border-light)',
fontSize: 13, fontWeight: 600,
};

View file

@ -0,0 +1,207 @@
import { useState } from 'react';
import { Link, useParams, Navigate } from 'react-router-dom';
import { useApprovalContext, useApprovalByToken, useDecideApproval } from '../api/approvals';
import { useMe } from '../api/users';
import { GATED_STAGES, STAGE_TITLES } from '../types';
export default function ApprovalView() {
const params = useParams<{ id?: string; token?: string }>();
const idNum = params.id ? parseInt(params.id, 10) : undefined;
const token = params.token;
const byId = useApprovalContext(idNum);
const byToken = useApprovalByToken(token);
const ctxQuery = token ? byToken : byId;
const { data: me } = useMe();
const decide = useDecideApproval();
const [comment, setComment] = useState('');
const [error, setError] = useState<string | null>(null);
if (!idNum && !token) return <Navigate to="/" replace />;
if (ctxQuery.isLoading) return <div style={{ color: 'var(--color-text-muted)' }}>Loading approval</div>;
if (ctxQuery.error || !ctxQuery.data) return <div style={{ color: '#fca5a5' }}>Approval not found.</div>;
const ctx = ctxQuery.data;
const a = ctx.approval;
const decided = a.status !== 'pending';
const isMyApproval = me && a.approver_user_id === me.id;
const canDecide = !decided && (isMyApproval || me?.role === 'admin');
const stageGated = GATED_STAGES.has(a.stage_number);
async function handleDecide(decision: 'approve' | 'reject') {
setError(null);
try {
await decide.mutateAsync({ approvalId: a.id, decision, comment: comment.trim() || null });
} catch (err: any) {
setError(err?.response?.data?.detail ?? err?.message ?? 'Failed to submit decision');
}
}
return (
<div style={{ maxWidth: 720 }}>
<Link to={`/opportunities/${ctx.opportunity_id}`} style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>
Back to opportunity
</Link>
<header style={{ marginTop: 12 }}>
<h1 style={{ margin: 0, fontSize: 24, letterSpacing: '-0.02em' }}>
Approval: {ctx.opportunity_name}
</h1>
<div style={{ color: 'var(--color-text-secondary)', marginTop: 6, fontSize: 13 }}>
Stage {a.stage_number} {STAGE_TITLES[a.stage_number]}
{stageGated && <span style={gateBadge}>APPROVAL GATE</span>}
</div>
</header>
<section style={cardStyle}>
<h2 style={sectionTitleStyle}>Opportunity context</h2>
<Row label="Client" value={ctx.client_name} />
<Row label="Region" value={ctx.region} />
<Row label="Deadline" value={ctx.deadline} />
<Row label="Your role" value={a.role_required} />
{ctx.summary && (
<div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--color-border-light)' }}>
<div style={metaLabelStyle}>Summary</div>
<div style={summaryTextStyle}>{ctx.summary}</div>
</div>
)}
</section>
<section style={{ ...cardStyle, marginTop: 18 }}>
<h2 style={sectionTitleStyle}>Status</h2>
<div style={{ marginTop: 8 }}>
<span style={{ ...statusBadgeStyle, ...statusBadgeColor(a.status) }}>{a.status.toUpperCase()}</span>
{a.requested_at && (
<span style={{ marginLeft: 12, color: 'var(--color-text-muted)', fontSize: 12 }}>
Requested {new Date(a.requested_at).toLocaleString()}
</span>
)}
{a.decided_at && (
<span style={{ marginLeft: 12, color: 'var(--color-text-muted)', fontSize: 12 }}>
Decided {new Date(a.decided_at).toLocaleString()}
</span>
)}
</div>
{a.comment && (
<div style={commentBlockStyle}>
<div style={metaLabelStyle}>Decision notes</div>
<div style={{ marginTop: 4, fontSize: 13, lineHeight: 1.55 }}>"{a.comment}"</div>
</div>
)}
</section>
{canDecide && (
<section style={{ ...cardStyle, marginTop: 18 }}>
<h2 style={sectionTitleStyle}>Decide</h2>
<p style={{ color: 'var(--color-text-muted)', fontSize: 12, marginTop: 4, marginBottom: 12 }}>
Add notes if you want they'll be visible to whoever reviews this opportunity later. Required for rejections.
</p>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Notes on your decision (optional for approval, please give a reason if rejecting)…"
rows={5}
style={textareaStyle}
/>
{error && <div style={errorStyle}>{error}</div>}
<div style={{ display: 'flex', gap: 10, marginTop: 14 }}>
<button
onClick={() => handleDecide('approve')}
disabled={decide.isPending}
style={approveBtnStyle}
>
{decide.isPending ? 'Submitting…' : '✓ Approve'}
</button>
<button
onClick={() => handleDecide('reject')}
disabled={decide.isPending || !comment.trim()}
title={!comment.trim() ? 'Please provide a reason for rejection' : ''}
style={rejectBtnStyle}
>
Reject
</button>
</div>
</section>
)}
{decided && !canDecide && (
<section style={{ ...cardStyle, marginTop: 18, color: 'var(--color-text-muted)' }}>
This approval has already been {a.status}. You're viewing it as a record.
</section>
)}
{!decided && !canDecide && (
<section style={{ ...cardStyle, marginTop: 18, color: 'var(--color-text-muted)' }}>
You're not the assigned approver for this request.
{a.approver?.email && <> The approver is <strong>{a.approver.email}</strong>.</>}
</section>
)}
</div>
);
}
function Row({ label, value }: { label: string; value?: string | null }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12, padding: '6px 0' }}>
<span style={metaLabelStyle}>{label}</span>
<span style={{ color: value ? 'var(--color-text)' : 'var(--color-text-muted)', fontSize: 13 }}>
{value || '—'}
</span>
</div>
);
}
function statusBadgeColor(status: string): React.CSSProperties {
if (status === 'approved') return { background: 'rgba(46,125,50,0.14)', color: '#86efac' };
if (status === 'rejected') return { background: 'rgba(239,68,68,0.14)', color: '#fca5a5' };
return { background: 'rgba(255,196,7,0.12)', color: '#FFC407' };
}
const cardStyle: React.CSSProperties = {
background: 'var(--color-bg-card)', border: '1px solid var(--color-border)',
borderRadius: 12, padding: 22, marginTop: 18,
};
const sectionTitleStyle: React.CSSProperties = {
margin: 0, fontSize: 15, fontWeight: 600,
};
const metaLabelStyle: React.CSSProperties = {
color: 'var(--color-text-muted)', fontSize: 11,
textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600,
};
const summaryTextStyle: React.CSSProperties = {
color: 'var(--color-text)', fontSize: 13, lineHeight: 1.55, marginTop: 6,
};
const statusBadgeStyle: React.CSSProperties = {
display: 'inline-block', fontSize: 11, fontWeight: 700,
letterSpacing: '0.05em', padding: '4px 10px', borderRadius: 12,
};
const commentBlockStyle: React.CSSProperties = {
marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--color-border-light)',
};
const textareaStyle: React.CSSProperties = {
width: '100%', padding: 12, background: 'var(--color-bg-input)',
border: '1px solid var(--color-border)', borderRadius: 8,
color: 'var(--color-text)', fontSize: 13, fontFamily: 'inherit',
resize: 'vertical',
};
const errorStyle: React.CSSProperties = {
marginTop: 12, background: 'rgba(239,68,68,0.10)',
border: '1px solid rgba(239,68,68,0.3)', color: '#fca5a5',
padding: '10px 12px', borderRadius: 8, fontSize: 13,
};
const approveBtnStyle: React.CSSProperties = {
background: '#2E7D32', color: '#fff', border: 'none',
padding: '11px 20px', borderRadius: 8, fontWeight: 600, fontSize: 14, cursor: 'pointer',
};
const rejectBtnStyle: React.CSSProperties = {
background: 'transparent', color: '#fca5a5',
border: '1px solid rgba(239,68,68,0.5)',
padding: '11px 20px', borderRadius: 8, fontWeight: 600, fontSize: 14, cursor: 'pointer',
};
const gateBadge: React.CSSProperties = {
marginLeft: 10, fontSize: 10, fontWeight: 700, letterSpacing: '0.05em',
padding: '3px 8px', borderRadius: 12,
background: 'rgba(55,48,163,0.25)', color: '#a5b4fc', verticalAlign: 'middle',
};

View file

@ -3,6 +3,7 @@ import { useOpportunity, useStages, useCompleteStage } from '../api/opportunitie
import { GATED_STAGES, MODEL_TYPE_LABELS, STAGE_TITLES } from '../types';
import StageStepper from '../components/StageStepper';
import Stage1Intake from '../components/Stage1Intake';
import StageApprovals from '../components/StageApprovals';
export default function OpportunityView() {
const { id, stageNumber } = useParams<{ id: string; stageNumber?: string }>();
@ -84,13 +85,21 @@ export default function OpportunityView() {
<Stage1Intake opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
)}
{activeStage > 1 && (
{activeStage > 1 && !GATED_STAGES.has(activeStage) && (
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>
This stage isn't built yet — the state machine runs but there's no agent, UI, or artifact persistence
specific to it. Advancing here is harmless on a test opportunity.
</div>
)}
{GATED_STAGES.has(activeStage) && (
<StageApprovals
opportunityId={opportunityId}
stageNumber={activeStage}
canEdit={stageState?.status !== 'completed'}
/>
)}
{canCompleteHere && stageN && (
<div style={{ marginTop: 18 }}>
<button onClick={handleComplete} disabled={complete.isPending} style={primaryBtnStyle}>

View file

@ -115,3 +115,67 @@ export interface IntakeMetadata {
go_live_iso?: string;
summary?: string;
}
export interface UserBrief {
id: number;
email: string;
name: string | null;
role: 'viewer' | 'editor' | 'admin';
last_login: string | null;
}
export type ApprovalStatusKey = 'pending' | 'approved' | 'rejected';
export interface Approval {
id: number;
opportunity_id: number;
stage_number: number;
role_required: string;
approver_user_id: number | null;
approver: { id: number; email: string; name: string | null } | null;
status: ApprovalStatusKey;
comment: string | null;
requested_at: string;
decided_at: string | null;
email_sent_at: string | null;
email_to: string | null;
}
export interface ApprovalContext {
approval: Approval;
opportunity_name: string;
opportunity_id: number;
client_name: string | null;
region: string | null;
deadline: string | null;
summary: string | null;
}
export type NotificationTypeKey =
| 'approval_requested'
| 'approval_approved'
| 'approval_rejected'
| 'stage_completed'
| 'generic';
export interface Notification {
id: number;
type: NotificationTypeKey;
title: string;
body: string | null;
related_opportunity_id: number | null;
related_approval_id: number | null;
link_path: string | null;
read: boolean;
created_at: string;
read_at: string | null;
}
export const APPROVAL_ROLES = [
'commercial',
'delivery',
'solution',
'regional',
'deal_desk',
] as const;
export type ApprovalRole = typeof APPROVAL_ROLES[number];