mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 04:13:13 +00:00
Apply UX quick wins from external review
- Replace $0.00 with "Not priced" via formatBidAmount helper - Consistent login buttons with role descriptions - Context-aware empty states (filter mismatch vs no data) - Clear Filters button on proposal list and admin dashboard - Rename "Regenerate" to "Regenerate Suggested Line Items" - Add tooltips explaining disabled Save/Approve buttons - Replace "RAG engine" jargon with plain language - Improve User Management placeholder with Cognito guidance - Add Puppeteer screenshot script for all roles/pages
This commit is contained in:
parent
ef052a81ac
commit
85bed9a161
9 changed files with 240 additions and 46 deletions
131
scripts/screenshot-pages.mjs
Normal file
131
scripts/screenshot-pages.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const BASE = 'http://localhost:5173';
|
||||||
|
const OUT = process.env.HOME + '/Desktop/proposal-system-screenshots';
|
||||||
|
|
||||||
|
const ROLES = [
|
||||||
|
{ label: 'SysAdmin', prefix: 'sysadmin', isAdmin: true, isSysAdmin: true },
|
||||||
|
{ label: 'Admin', prefix: 'admin', isAdmin: true, isSysAdmin: false },
|
||||||
|
{ label: 'Dispatcher', prefix: 'dispatcher', isAdmin: false, isSysAdmin: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function wait(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
async function screenshot(page, name) {
|
||||||
|
await wait(1500);
|
||||||
|
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: true });
|
||||||
|
console.log(` captured ${name}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getToken(page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem('proposal_system_token');
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed?.token ?? null;
|
||||||
|
} catch { return null; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProposalId(page) {
|
||||||
|
const token = await getToken(page);
|
||||||
|
if (!token) return null;
|
||||||
|
return page.evaluate(async (t) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/proposals?page=1&pageSize=1', {
|
||||||
|
headers: { 'Authorization': `Bearer ${t}` },
|
||||||
|
});
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const data = await r.json();
|
||||||
|
return data.items?.[0]?.id ?? null;
|
||||||
|
} catch { return null; }
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
defaultViewport: { width: 1440, height: 900 },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const role of ROLES) {
|
||||||
|
console.log(`\n--- ${role.prefix} ---`);
|
||||||
|
|
||||||
|
const context = await browser.createBrowserContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// Login page
|
||||||
|
await page.goto(BASE + '/login', { waitUntil: 'networkidle2' });
|
||||||
|
await screenshot(page, `${role.prefix}-01-login`);
|
||||||
|
|
||||||
|
// Click the role button
|
||||||
|
const buttons = await page.$$('button');
|
||||||
|
let clicked = false;
|
||||||
|
for (const b of buttons) {
|
||||||
|
const text = await b.evaluate(el => el.textContent.trim());
|
||||||
|
if (text.startsWith(role.label)) {
|
||||||
|
await b.click();
|
||||||
|
clicked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!clicked) {
|
||||||
|
console.log(` ERROR: could not find button "${role.label}"`);
|
||||||
|
await context.close();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 10000 }).catch(() => {});
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
await screenshot(page, `${role.prefix}-02-dashboard`);
|
||||||
|
|
||||||
|
// My Proposals list
|
||||||
|
await page.goto(BASE + '/proposals', { waitUntil: 'networkidle2' });
|
||||||
|
await screenshot(page, `${role.prefix}-03-proposals-list`);
|
||||||
|
|
||||||
|
// New Proposal form
|
||||||
|
await page.goto(BASE + '/proposals/new', { waitUntil: 'networkidle2' });
|
||||||
|
await screenshot(page, `${role.prefix}-04-new-proposal`);
|
||||||
|
|
||||||
|
// Detail page
|
||||||
|
const proposalId = await fetchProposalId(page);
|
||||||
|
if (proposalId) {
|
||||||
|
await page.goto(BASE + `/proposals/${proposalId}`, { waitUntil: 'networkidle2' });
|
||||||
|
await screenshot(page, `${role.prefix}-05-proposal-detail`);
|
||||||
|
} else {
|
||||||
|
console.log(' no proposals found, skipping detail');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.isAdmin) {
|
||||||
|
await page.goto(BASE + '/admin', { waitUntil: 'networkidle2' });
|
||||||
|
await screenshot(page, `${role.prefix}-06-admin-queue`);
|
||||||
|
|
||||||
|
await page.goto(BASE + '/admin/proposals', { waitUntil: 'networkidle2' });
|
||||||
|
await screenshot(page, `${role.prefix}-07-admin-all-proposals`);
|
||||||
|
|
||||||
|
const workspaceId = proposalId ?? await fetchProposalId(page);
|
||||||
|
if (workspaceId) {
|
||||||
|
await page.goto(BASE + `/admin/proposals/${workspaceId}`, { waitUntil: 'networkidle2' });
|
||||||
|
await wait(2000);
|
||||||
|
await screenshot(page, `${role.prefix}-08-admin-workspace`);
|
||||||
|
} else {
|
||||||
|
console.log(' no proposals for workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.isSysAdmin) {
|
||||||
|
await page.goto(BASE + '/admin/users', { waitUntil: 'networkidle2' });
|
||||||
|
await screenshot(page, `${role.prefix}-09-user-management`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(`\nDone — screenshots in ${OUT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(e => { console.error(e); process.exit(1); });
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { Box, Toolbar, Typography } from '@mui/material';
|
import { Box, Toolbar, Typography } from '@mui/material';
|
||||||
import { selectSidebarOpen } from './app/slices/uiSlice';
|
|
||||||
import type { RootState } from './app/store';
|
|
||||||
import ProtectedRoute, { RoleGuard } from './components/ProtectedRoute';
|
import ProtectedRoute, { RoleGuard } from './components/ProtectedRoute';
|
||||||
import Topbar from './components/Topbar';
|
import Topbar from './components/Topbar';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
|
|
@ -14,11 +11,7 @@ import ProposalDetailPage from './pages/proposals/detail/ProposalDetailPage';
|
||||||
import ProposalListPage from './pages/proposals/list/ProposalListPage';
|
import ProposalListPage from './pages/proposals/list/ProposalListPage';
|
||||||
import AdminDashboard from './pages/admin/dashboard/AdminDashboard';
|
import AdminDashboard from './pages/admin/dashboard/AdminDashboard';
|
||||||
import AdminWorkspace from './pages/admin/workspace/AdminWorkspace';
|
import AdminWorkspace from './pages/admin/workspace/AdminWorkspace';
|
||||||
import { DRAWER_WIDTH } from './constants';
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const sidebarOpen = useSelector((state: RootState) => selectSidebarOpen(state));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
@ -36,8 +29,6 @@ export default function App() {
|
||||||
sx={{
|
sx={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
p: 1.25,
|
p: 1.25,
|
||||||
ml: sidebarOpen ? `${DRAWER_WIDTH}px` : 0,
|
|
||||||
transition: 'margin-left 250ms ease',
|
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -52,7 +43,19 @@ export default function App() {
|
||||||
<Route path="/admin" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard defaultStatus="InReview" /></RoleGuard>} />
|
<Route path="/admin" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard defaultStatus="InReview" /></RoleGuard>} />
|
||||||
<Route path="/admin/proposals" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} />
|
<Route path="/admin/proposals" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} />
|
||||||
<Route path="/admin/proposals/:id" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminWorkspace /></RoleGuard>} />
|
<Route path="/admin/proposals/:id" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminWorkspace /></RoleGuard>} />
|
||||||
<Route path="/admin/users" element={<RoleGuard roles={['SysAdmin']}><Typography variant="h5" sx={{ p: 2 }}>User Management — Coming Soon</Typography></RoleGuard>} />
|
<Route path="/admin/users" element={
|
||||||
|
<RoleGuard roles={['SysAdmin']}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
|
||||||
|
<Box sx={{ textAlign: 'center', maxWidth: 400 }}>
|
||||||
|
<Typography variant="h5" sx={{ mb: 1 }}>User Management</Typography>
|
||||||
|
<Typography variant="h6" color="text.secondary" sx={{ mb: 2, fontWeight: 400 }}>Coming Soon</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Users and roles are currently managed in Cognito. Contact the system administrator to update access.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</RoleGuard>
|
||||||
|
} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disa
|
||||||
|
|
||||||
{(!similar || similar.length === 0) && (
|
{(!similar || similar.length === 0) && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
No similar proposals found. AI suggestions will populate this panel once the RAG engine is active.
|
Similar proposals will appear here when available.
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@ export function formatCurrency(amount: number): string {
|
||||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatBidAmount(amount: number): string {
|
||||||
|
return amount > 0 ? formatCurrency(amount) : 'Not priced';
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(iso: string): string {
|
export function formatDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
TextField,
|
TextField,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
Button,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
|
@ -32,7 +33,7 @@ import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals'
|
||||||
import { adminApi, type DashboardStats } from '../../../lib/api/admin';
|
import { adminApi, type DashboardStats } from '../../../lib/api/admin';
|
||||||
import { QUERY_KEYS } from '../../../constants/queryKeys';
|
import { QUERY_KEYS } from '../../../constants/queryKeys';
|
||||||
import { SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_COLORS, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_COLORS, PRIORITY_ROW_SX } from '../../../constants';
|
import { SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_COLORS, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_COLORS, PRIORITY_ROW_SX } from '../../../constants';
|
||||||
import { formatCurrency, formatDate } from '../../../lib/format';
|
import { formatBidAmount, formatDate } from '../../../lib/format';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||||
|
|
||||||
function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) {
|
function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) {
|
||||||
|
|
@ -194,6 +195,14 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
<MenuItem key={s} value={s}>{STATUS_LABELS[s] || s}</MenuItem>
|
<MenuItem key={s} value={s}>{STATUS_LABELS[s] || s}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
{(statusFilter || categoryFilter || priorityFilter || search) && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setStatusFilter(defaultStatus ?? ''); setCategoryFilter(''); setPriorityFilter(''); setSearch(''); }}
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{err && (
|
{err && (
|
||||||
|
|
@ -246,7 +255,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip label={STATUS_LABELS[row.status] || row.status} size="small" color={STATUS_COLORS[row.status] || 'default'} />
|
<Chip label={STATUS_LABELS[row.status] || row.status} size="small" color={STATUS_COLORS[row.status] || 'default'} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
<TableCell align="right">{formatBidAmount(row.totalBidAmount)}</TableCell>
|
||||||
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
||||||
<TableCell>{row.submittedByName || '-'}</TableCell>
|
<TableCell>{row.submittedByName || '-'}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -254,7 +263,16 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
{!loading && rows.length === 0 && (
|
{!loading && rows.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9} align="center" sx={{ py: 4 }}>
|
<TableCell colSpan={9} align="center" sx={{ py: 4 }}>
|
||||||
<Typography color="text.secondary">No proposals in queue</Typography>
|
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
{(statusFilter || categoryFilter || priorityFilter || search)
|
||||||
|
? 'No proposals match your current filters.'
|
||||||
|
: 'No proposals in queue.'}
|
||||||
|
</Typography>
|
||||||
|
{(statusFilter || categoryFilter || priorityFilter || search) && (
|
||||||
|
<Button size="small" onClick={() => { setStatusFilter(defaultStatus ?? ''); setCategoryFilter(''); setPriorityFilter(''); setSearch(''); }}>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||||
import UndoIcon from '@mui/icons-material/Undo';
|
import UndoIcon from '@mui/icons-material/Undo';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
|
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
|
||||||
import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems';
|
import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems';
|
||||||
|
|
@ -517,25 +518,33 @@ export default function AdminWorkspace() {
|
||||||
onClick={() => regenerateMutation.mutate()}
|
onClick={() => regenerateMutation.mutate()}
|
||||||
disabled={regenerateMutation.isPending}
|
disabled={regenerateMutation.isPending}
|
||||||
>
|
>
|
||||||
Regenerate
|
Regenerate Suggested Line Items
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<SaveIcon />}
|
|
||||||
onClick={() => saveMutation.mutate()}
|
|
||||||
disabled={saveMutation.isPending || !dirty}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="success"
|
|
||||||
startIcon={<CheckCircleIcon />}
|
|
||||||
onClick={() => setApproveDialogOpen(true)}
|
|
||||||
disabled={lineItems.length === 0 || bidTotal <= 0}
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<Tooltip title={!dirty ? 'No unsaved changes' : ''}>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={saveMutation.isPending || !dirty}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={lineItems.length === 0 ? 'Add at least one line item before approving' : bidTotal <= 0 ? 'Total bid must be greater than $0' : ''}>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
startIcon={<CheckCircleIcon />}
|
||||||
|
onClick={() => setApproveDialogOpen(true)}
|
||||||
|
disabled={lineItems.length === 0 || bidTotal <= 0}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ export default function LoginPage() {
|
||||||
{isDevMode ? (
|
{isDevMode ? (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
Development Mode — Select a role to sign in as:
|
Development Sign-In — Select a role to preview the app.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -123,7 +123,10 @@ export default function LoginPage() {
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
sx={{ py: 1.5, bgcolor: '#003A5D', '&:hover': { bgcolor: '#00527d' } }}
|
sx={{ py: 1.5, bgcolor: '#003A5D', '&:hover': { bgcolor: '#00527d' } }}
|
||||||
>
|
>
|
||||||
Sign in as SysAdmin
|
<Box sx={{ textAlign: 'left', flex: 1 }}>
|
||||||
|
<Box>SysAdmin</Box>
|
||||||
|
<Box sx={{ fontSize: '11px', fontWeight: 400, opacity: 0.8 }}>Full access</Box>
|
||||||
|
</Box>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
|
@ -132,25 +135,31 @@ export default function LoginPage() {
|
||||||
startIcon={<EngineeringIcon />}
|
startIcon={<EngineeringIcon />}
|
||||||
onClick={() => handleDevLogin('Admin')}
|
onClick={() => handleDevLogin('Admin')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
sx={{ py: 1.5, bgcolor: '#0c4f6f', '&:hover': { bgcolor: '#083a52' } }}
|
sx={{ py: 1.5, bgcolor: '#003A5D', '&:hover': { bgcolor: '#00527d' } }}
|
||||||
>
|
>
|
||||||
Sign in as Admin
|
<Box sx={{ textAlign: 'left', flex: 1 }}>
|
||||||
|
<Box>Admin</Box>
|
||||||
|
<Box sx={{ fontSize: '11px', fontWeight: 400, opacity: 0.8 }}>Review and approve proposals</Box>
|
||||||
|
</Box>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
fullWidth
|
fullWidth
|
||||||
startIcon={<PersonIcon />}
|
startIcon={<PersonIcon />}
|
||||||
onClick={() => handleDevLogin('Dispatcher')}
|
onClick={() => handleDevLogin('Dispatcher')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
sx={{ py: 1.5 }}
|
sx={{ py: 1.5, bgcolor: '#003A5D', '&:hover': { bgcolor: '#00527d' } }}
|
||||||
>
|
>
|
||||||
Sign in as Dispatcher
|
<Box sx={{ textAlign: 'left', flex: 1 }}>
|
||||||
|
<Box>Dispatcher</Box>
|
||||||
|
<Box sx={{ fontSize: '11px', fontWeight: 400, opacity: 0.8 }}>Submit and track proposals</Box>
|
||||||
|
</Box>
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Dev login is only available when VITE_COGNITO_CLIENT_ID is not set
|
Dev login is only shown when Cognito is not configured.
|
||||||
</Typography>
|
</Typography>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
import { proposalsApi, type ProposalDetail, type PdfVersion } from '../../../lib/api/proposals';
|
import { proposalsApi, type ProposalDetail, type PdfVersion } from '../../../lib/api/proposals';
|
||||||
import { STATUS_COLORS, STATUS_LABELS, PRIORITY_LABELS } from '../../../constants';
|
import { STATUS_COLORS, STATUS_LABELS, PRIORITY_LABELS } from '../../../constants';
|
||||||
import { formatCurrency, formatDateTime } from '../../../lib/format';
|
import { formatBidAmount, formatCurrency, formatDateTime } from '../../../lib/format';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||||
|
|
||||||
const STATUS_ORDER = ['Draft', 'InReview', 'Approved', 'Sent'];
|
const STATUS_ORDER = ['Draft', 'InReview', 'Approved', 'Sent'];
|
||||||
|
|
@ -181,7 +181,7 @@ export default function ProposalDetailPage() {
|
||||||
<InfoRow label="Revision" value={`#${proposal.currentRevision}`} />
|
<InfoRow label="Revision" value={`#${proposal.currentRevision}`} />
|
||||||
<InfoRow label="Submitted By" value={proposal.submittedByName} />
|
<InfoRow label="Submitted By" value={proposal.submittedByName} />
|
||||||
<InfoRow label="Submitted" value={formatDateTime(proposal.submittedAt)} />
|
<InfoRow label="Submitted" value={formatDateTime(proposal.submittedAt)} />
|
||||||
<InfoRow label="Bid Amount" value={formatCurrency(proposal.totalBidAmount)} />
|
<InfoRow label="Bid Amount" value={formatBidAmount(proposal.totalBidAmount)} />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import ReportIcon from '@mui/icons-material/Report';
|
||||||
import { usePaginatedList } from '../../../hooks/usePaginatedList';
|
import { usePaginatedList } from '../../../hooks/usePaginatedList';
|
||||||
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
|
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
|
||||||
import { STATUS_COLORS, STATUS_LABELS, PROPOSAL_STATUSES, SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS, PRIORITY_COLORS, PRIORITY_ROW_SX } from '../../../constants';
|
import { STATUS_COLORS, STATUS_LABELS, PROPOSAL_STATUSES, SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS, PRIORITY_COLORS, PRIORITY_ROW_SX } from '../../../constants';
|
||||||
import { formatCurrency, formatDate } from '../../../lib/format';
|
import { formatBidAmount, formatDate } from '../../../lib/format';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||||
|
|
||||||
type SortKey = keyof ProposalListItem;
|
type SortKey = keyof ProposalListItem;
|
||||||
|
|
@ -161,6 +161,14 @@ export default function ProposalListPage() {
|
||||||
<MenuItem key={p} value={p}>{PRIORITY_LABELS[p] || p}</MenuItem>
|
<MenuItem key={p} value={p}>{PRIORITY_LABELS[p] || p}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
{(filterStatus || filterCategory || filterPriority || search) && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setFilterStatus(''); setFilterCategory(''); setFilterPriority(''); setSearch(''); }}
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{err && (
|
{err && (
|
||||||
|
|
@ -223,16 +231,28 @@ export default function ProposalListPage() {
|
||||||
color={STATUS_COLORS[row.status] || 'default'}
|
color={STATUS_COLORS[row.status] || 'default'}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
<TableCell align="right">{formatBidAmount(row.totalBidAmount)}</TableCell>
|
||||||
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{!loading && rows.length === 0 && (
|
{!loading && rows.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
||||||
<Typography color="text.secondary">
|
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
||||||
No proposals found. Create your first one!
|
{(filterStatus || filterCategory || filterPriority || search)
|
||||||
|
? 'No proposals match your current filters.'
|
||||||
|
: 'You have not submitted any proposals yet.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||||
|
{(filterStatus || filterCategory || filterPriority || search) && (
|
||||||
|
<Button size="small" onClick={() => { setFilterStatus(''); setFilterCategory(''); setFilterPriority(''); setSearch(''); }}>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="small" variant="contained" startIcon={<AddCircleIcon />} onClick={() => navigate('/proposals/new')}>
|
||||||
|
New Proposal
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue