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:
parent
3f7531a1bf
commit
f631dad00b
9 changed files with 823 additions and 1 deletions
|
|
@ -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>
|
||||
|
|
|
|||
98
frontend/src/api/approvals.ts
Normal file
98
frontend/src/api/approvals.ts
Normal 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
56
frontend/src/api/notifications.ts
Normal file
56
frontend/src/api/notifications.ts
Normal 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
23
frontend/src/api/users.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
138
frontend/src/components/NotificationBell.tsx
Normal file
138
frontend/src/components/NotificationBell.tsx
Normal 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,
|
||||
};
|
||||
222
frontend/src/components/StageApprovals.tsx
Normal file
222
frontend/src/components/StageApprovals.tsx
Normal 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,
|
||||
};
|
||||
207
frontend/src/pages/ApprovalView.tsx
Normal file
207
frontend/src/pages/ApprovalView.tsx
Normal 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',
|
||||
};
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue