diff --git a/web/src/App.tsx b/web/src/App.tsx
index 46d7756..99a77e4 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,6 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
-import { Box, Toolbar } from '@mui/material';
+import { Box, Toolbar, Typography } from '@mui/material';
import { selectSidebarOpen } from './app/slices/uiSlice';
import type { RootState } from './app/store';
import ProtectedRoute from './components/ProtectedRoute';
@@ -15,7 +15,7 @@ import ProposalListPage from './pages/proposals/list/ProposalListPage';
import AdminDashboard from './pages/admin/dashboard/AdminDashboard';
import AdminWorkspace from './pages/admin/workspace/AdminWorkspace';
-const DRAWER_WIDTH = 240;
+const DRAWER_WIDTH = 220;
export default function App() {
const sidebarOpen = useSelector((state: RootState) => selectSidebarOpen(state));
@@ -36,12 +36,13 @@ export default function App() {
component="main"
sx={{
flexGrow: 1,
- p: 3,
+ p: '10px',
ml: sidebarOpen ? `${DRAWER_WIDTH}px` : 0,
- transition: 'margin-left 225ms cubic-bezier(0.4, 0, 0.6, 1)',
+ transition: 'margin-left 250ms ease',
+ minHeight: '100vh',
}}
>
-
+
} />
} />
@@ -52,6 +53,7 @@ export default function App() {
} />
} />
} />
+ User Management — Coming Soon} />
} />
diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts
index be4ca1e..f7dfc06 100644
--- a/web/src/lib/api/admin.ts
+++ b/web/src/lib/api/admin.ts
@@ -64,4 +64,13 @@ export const adminApi = {
const res = await apiClient.get(`/proposals/${id}/similar`);
return res.data;
},
+
+ getPdf: async (id: string): Promise<{ downloadUrl: string; expiresAt: string } | null> => {
+ try {
+ const res = await apiClient.get(`/proposals/${id}/pdf`);
+ return res.data;
+ } catch {
+ return null;
+ }
+ },
};
diff --git a/web/src/lib/api/proposals.ts b/web/src/lib/api/proposals.ts
index a617eb4..09fdbb9 100644
--- a/web/src/lib/api/proposals.ts
+++ b/web/src/lib/api/proposals.ts
@@ -97,4 +97,16 @@ export const proposalsApi = {
const res = await apiClient.post(`/proposals/${proposalId}/attachments?fileName=${encodeURIComponent(fileName)}`);
return res.data;
},
+
+ getStats: async (): Promise => {
+ const res = await apiClient.get('/proposals/stats');
+ return res.data;
+ },
};
+
+export interface ProposalStats {
+ totalCount: number;
+ inReviewCount: number;
+ approvedCount: number;
+ sentCount: number;
+}
diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx
index 488396d..680d916 100644
--- a/web/src/pages/admin/workspace/AdminWorkspace.tsx
+++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx
@@ -146,6 +146,21 @@ export default function AdminWorkspace() {
},
});
+ const pdfMutation = useMutation({
+ mutationFn: () => adminApi.getPdf(id!),
+ onSuccess: (data) => {
+ if (data?.downloadUrl) {
+ window.open(data.downloadUrl, '_blank');
+ toast.success('PDF ready — downloading');
+ } else {
+ toast.info('PDF generation queued — try again in a few seconds');
+ }
+ },
+ onError: () => {
+ toast.error('Failed to generate PDF');
+ },
+ });
+
const handleLineItemsChange = (updated: EditableLineItem[]) => {
setLineItems(updated);
setDirty(true);
@@ -342,9 +357,10 @@ export default function AdminWorkspace() {
}
- onClick={() => toast.info('PDF generation will be available in Phase 5')}
+ onClick={() => pdfMutation.mutate()}
+ disabled={pdfMutation.isPending}
>
- Generate PDF
+ {pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
-
-
- setSearch(e.target.value)}
- sx={{ mb: 2, width: 320 }}
- slotProps={{
- input: {
- startAdornment: (
-
-
-
- ),
- },
- }}
- />
-
- {err && (
-
- {err}
-
+
+
+ {isLoading ? (
+
+ ) : (
+ } label="Total Submitted" value={String(total)} />
)}
+
+
+ {isLoading ? (
+
+ ) : (
+ } label="In Review" value={String(counts.inReview)} />
+ )}
+
+
+ {isLoading ? (
+
+ ) : (
+ } label="Approved" value={String(counts.approved)} />
+ )}
+
+
+ {isLoading ? (
+
+ ) : (
+ } label="Sent" value={String(counts.sent)} />
+ )}
+
+
+
+
+
+
+ Recent Proposals
+ navigate('/proposals')}>
+ View All
+
+
@@ -97,26 +137,22 @@ export default function Dashboard() {
Proposal #
Customer
- WO #
Category
- Priority
Status
Bid Amount
Submitted
- {loading
+ {isLoading
? Array.from({ length: 5 }).map((_, i) => (
- {Array.from({ length: 8 }).map((_, j) => (
-
-
-
+ {Array.from({ length: 6 }).map((_, j) => (
+
))}
))
- : rows.map((row) => (
+ : proposals.map((row) => (
{row.proposalNumber}
{row.customerName}
- {row.workOrderNumber}
{row.serviceCategory}
- {row.priority}
-
+
{formatCurrency(row.totalBidAmount)}
{formatDate(row.submittedAt)}
))}
- {!loading && rows.length === 0 && (
+ {!isLoading && proposals.length === 0 && (
-
+
- No proposals found. Create your first one!
+ No proposals yet. Submit your first one!
@@ -151,16 +181,6 @@ export default function Dashboard() {
-
- setPage(newPage + 1)}
- rowsPerPage={pageSize}
- onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))}
- rowsPerPageOptions={[12, 24, 48]}
- />
diff --git a/web/src/pages/proposals/detail/ProposalDetailPage.tsx b/web/src/pages/proposals/detail/ProposalDetailPage.tsx
index 276192a..cacbecc 100644
--- a/web/src/pages/proposals/detail/ProposalDetailPage.tsx
+++ b/web/src/pages/proposals/detail/ProposalDetailPage.tsx
@@ -77,8 +77,8 @@ export default function ProposalDetailPage() {
{error instanceof Error ? error.message : 'Proposal not found'}
- } onClick={() => navigate('/')} sx={{ mt: 2 }}>
- Back to Dashboard
+ } onClick={() => navigate('/proposals')} sx={{ mt: 2 }}>
+ Back to Proposals
);
@@ -89,7 +89,7 @@ export default function ProposalDetailPage() {
return (
- } onClick={() => navigate('/')}>
+ } onClick={() => navigate('/proposals')}>
Back
diff --git a/web/src/pages/proposals/list/ProposalListPage.tsx b/web/src/pages/proposals/list/ProposalListPage.tsx
index 8196a26..a2197d9 100644
--- a/web/src/pages/proposals/list/ProposalListPage.tsx
+++ b/web/src/pages/proposals/list/ProposalListPage.tsx
@@ -1,5 +1,168 @@
-import Dashboard from '../../dashboard/Dashboard';
+import { useNavigate } from 'react-router-dom';
+import {
+ Box,
+ Typography,
+ Card,
+ CardContent,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TablePagination,
+ Chip,
+ TextField,
+ InputAdornment,
+ Button,
+ Skeleton,
+} from '@mui/material';
+import SearchIcon from '@mui/icons-material/Search';
+import AddCircleIcon from '@mui/icons-material/AddCircle';
+import { usePaginatedList } from '../../../hooks/usePaginatedList';
+import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
+
+const STATUS_COLORS: Record = {
+ Draft: 'default',
+ InReview: 'info',
+ Approved: 'success',
+ Sent: 'success',
+ Revised: 'warning',
+};
+
+function formatCurrency(amount: number): string {
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
+}
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+}
export default function ProposalListPage() {
- return ;
+ const navigate = useNavigate();
+ const {
+ rows,
+ search,
+ setSearch,
+ page,
+ setPage,
+ pageSize,
+ setPageSize,
+ totalCount,
+ loading,
+ err,
+ } = usePaginatedList(proposalsApi.getAll);
+
+ return (
+
+
+ My Proposals
+ }
+ onClick={() => navigate('/proposals/new')}
+ >
+ New Proposal
+
+
+
+
+
+ setSearch(e.target.value)}
+ sx={{ mb: 2, width: 320 }}
+ slotProps={{
+ input: {
+ startAdornment: (
+
+
+
+ ),
+ },
+ }}
+ />
+
+ {err && (
+
+ {err}
+
+ )}
+
+
+
+
+
+ Proposal #
+ Customer
+ WO #
+ Category
+ Priority
+ Status
+ Bid Amount
+ Submitted
+
+
+
+ {loading
+ ? Array.from({ length: 5 }).map((_, i) => (
+
+ {Array.from({ length: 8 }).map((_, j) => (
+
+
+
+ ))}
+
+ ))
+ : rows.map((row) => (
+ navigate(`/proposals/${row.id}`)}
+ >
+ {row.proposalNumber}
+ {row.customerName}
+ {row.workOrderNumber}
+ {row.serviceCategory}
+ {row.priority}
+
+
+
+ {formatCurrency(row.totalBidAmount)}
+ {formatDate(row.submittedAt)}
+
+ ))}
+ {!loading && rows.length === 0 && (
+
+
+
+ No proposals found. Create your first one!
+
+
+
+ )}
+
+
+
+
+ setPage(newPage + 1)}
+ rowsPerPage={pageSize}
+ onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))}
+ rowsPerPageOptions={[12, 24, 48]}
+ />
+
+
+
+ );
}