Implement Dispatcher Frontend (Phase 2)

React 19 + MUI v7 + TypeScript + Vite SPA matching SHOC patterns:
Redux Toolkit (auth/ui slices), TanStack React Query, axios interceptors,
react-toastify, Cognito OAuth PKCE login flow, paginated proposal list,
new proposal form with customer autocomplete and vendor PDF upload,
read-only proposal detail with status stepper timeline.
This commit is contained in:
Adam Moussa 2026-05-16 19:19:34 -04:00
parent 30e1da2c09
commit 378d0cc466
28 changed files with 4639 additions and 11 deletions

3121
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -11,12 +11,16 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mui/material": "^7.0.0", "@mui/icons-material": "^7.3.1",
"@tanstack/react-query": "^5.60.0", "@mui/material": "^7.3.1",
"axios": "^1.7.0", "@reduxjs/toolkit": "^2.11.2",
"react": "^19.0.0", "@tanstack/react-query": "^5.100.10",
"react-dom": "^19.0.0", "axios": "^1.16.0",
"react-router": "^7.0.0" "react": "^19.1.1",
"react-dom": "^19.1.1",
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.4",
"react-toastify": "^11.0.5"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",

57
web/src/App.tsx Normal file
View file

@ -0,0 +1,57 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { Box, Toolbar } from '@mui/material';
import { selectSidebarOpen } from './app/slices/uiSlice';
import type { RootState } from './app/store';
import ProtectedRoute from './components/ProtectedRoute';
import Topbar from './components/Topbar';
import Sidebar from './components/Sidebar';
import LoginPage from './pages/auth/LoginPage';
import AuthCallback from './pages/auth/AuthCallback';
import Dashboard from './pages/dashboard/Dashboard';
import ProposalFormPage from './pages/proposals/form/ProposalFormPage';
import ProposalDetailPage from './pages/proposals/detail/ProposalDetailPage';
import ProposalListPage from './pages/proposals/list/ProposalListPage';
const DRAWER_WIDTH = 240;
export default function App() {
const sidebarOpen = useSelector((state: RootState) => selectSidebarOpen(state));
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/callback" element={<AuthCallback />} />
<Route
path="/*"
element={
<ProtectedRoute>
<Box sx={{ display: 'flex' }}>
<Topbar />
<Sidebar />
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
ml: sidebarOpen ? `${DRAWER_WIDTH}px` : 0,
transition: 'margin-left 225ms cubic-bezier(0.4, 0, 0.6, 1)',
}}
>
<Toolbar />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/proposals" element={<ProposalListPage />} />
<Route path="/proposals/new" element={<ProposalFormPage />} />
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Box>
</Box>
</ProtectedRoute>
}
/>
</Routes>
);
}

View file

@ -0,0 +1,87 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { STORAGE_KEY_TOKEN } from '../../constants';
interface AuthUser {
id: string;
email: string;
displayName: string;
role: 'Dispatcher' | 'Admin' | 'SysAdmin';
token: string;
}
interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
loading: boolean;
error: string | null;
}
const getUserFromStorage = (): AuthUser | null => {
try {
const result = window.localStorage.getItem(STORAGE_KEY_TOKEN);
return result ? JSON.parse(result) : null;
} catch {
return null;
}
};
const isTokenValid = (token: string): boolean => {
if (!token) return false;
try {
const part = token.split('.')[1];
if (!part) return false;
const payload = JSON.parse(atob(part));
return new Date(payload.exp * 1000) > new Date();
} catch {
return false;
}
};
const storedUser = getUserFromStorage();
const initialState: AuthState = {
user: storedUser,
isAuthenticated: storedUser?.token ? isTokenValid(storedUser.token) : false,
loading: false,
error: null,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setUser: (state, action: PayloadAction<AuthUser>) => {
state.user = action.payload;
state.isAuthenticated = action.payload.token ? isTokenValid(action.payload.token) : false;
state.loading = false;
state.error = null;
window.localStorage.setItem(STORAGE_KEY_TOKEN, JSON.stringify(action.payload));
},
logout: (state) => {
state.user = null;
state.isAuthenticated = false;
state.error = null;
window.localStorage.removeItem(STORAGE_KEY_TOKEN);
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload;
state.loading = false;
},
clearError: (state) => {
state.error = null;
},
},
});
export const { setUser, logout, setLoading, setError, clearError } = authSlice.actions;
export const selectUser = (state: { auth: AuthState }) => state.auth.user;
export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.isAuthenticated;
export const selectAuthLoading = (state: { auth: AuthState }) => state.auth.loading;
export const selectAuthError = (state: { auth: AuthState }) => state.auth.error;
export const selectToken = (state: { auth: AuthState }) => state.auth.user?.token;
export default authSlice.reducer;

View file

@ -0,0 +1,29 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { STORAGE_KEY_SIDEBAR } from '../../constants';
interface UiState {
sidebarOpen: boolean;
}
const initialState: UiState = {
sidebarOpen: window.localStorage.getItem(STORAGE_KEY_SIDEBAR) !== 'false',
};
const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
toggleSidebar: (state) => {
state.sidebarOpen = !state.sidebarOpen;
window.localStorage.setItem(STORAGE_KEY_SIDEBAR, String(state.sidebarOpen));
},
setSidebarOpen: (state, action: PayloadAction<boolean>) => {
state.sidebarOpen = action.payload;
window.localStorage.setItem(STORAGE_KEY_SIDEBAR, String(action.payload));
},
},
});
export const { toggleSidebar, setSidebarOpen } = uiSlice.actions;
export const selectSidebarOpen = (state: { ui: UiState }) => state.ui.sidebarOpen;
export default uiSlice.reducer;

19
web/src/app/store.ts Normal file
View file

@ -0,0 +1,19 @@
import { configureStore } from '@reduxjs/toolkit';
import authReducer from './slices/authSlice';
import uiReducer from './slices/uiSlice';
export const store = configureStore({
reducer: {
auth: authReducer,
ui: uiReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['auth/setUser'],
},
}),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View file

@ -0,0 +1,12 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View file

@ -0,0 +1,71 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import {
Drawer,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Box,
} from '@mui/material';
import DashboardIcon from '@mui/icons-material/Dashboard';
import DescriptionIcon from '@mui/icons-material/Description';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import { selectSidebarOpen } from '../app/slices/uiSlice';
import { selectUser } from '../app/slices/authSlice';
import type { RootState } from '../app/store';
const DRAWER_WIDTH = 240;
const dispatcherNav = [
{ label: 'Dashboard', path: '/', icon: <DashboardIcon /> },
{ label: 'My Proposals', path: '/proposals', icon: <DescriptionIcon /> },
{ label: 'New Proposal', path: '/proposals/new', icon: <AddCircleIcon /> },
];
export default function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const open = useSelector((state: RootState) => selectSidebarOpen(state));
const user = useSelector((state: RootState) => selectUser(state));
const navItems = dispatcherNav;
return (
<Drawer
variant="persistent"
open={open}
sx={{
width: open ? DRAWER_WIDTH : 0,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box',
},
}}
>
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
<List>
{navItems.map((item) => (
<ListItemButton
key={item.path}
selected={location.pathname === item.path}
onClick={() => navigate(item.path)}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
))}
</List>
</Box>
{user && (
<Box sx={{ p: 2, mt: 'auto', borderTop: 1, borderColor: 'divider' }}>
<Box sx={{ fontSize: '0.85rem', fontWeight: 500 }}>{user.displayName}</Box>
<Box sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>{user.role}</Box>
</Box>
)}
</Drawer>
);
}

View file

@ -0,0 +1,37 @@
import { useDispatch } from 'react-redux';
import { AppBar, Toolbar, IconButton, Typography, Button, Box } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import LogoutIcon from '@mui/icons-material/Logout';
import { toggleSidebar } from '../app/slices/uiSlice';
import { useAuth } from '../hooks/useAuth';
export default function Topbar() {
const dispatch = useDispatch();
const { user, logout } = useAuth();
return (
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
<Toolbar>
<IconButton
color="inherit"
edge="start"
onClick={() => dispatch(toggleSidebar())}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap sx={{ flexGrow: 1 }}>
Proposal System
</Typography>
{user && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">{user.displayName}</Typography>
<Button color="inherit" onClick={logout} startIcon={<LogoutIcon />} size="small">
Logout
</Button>
</Box>
)}
</Toolbar>
</AppBar>
);
}

View file

@ -0,0 +1,21 @@
export const API_URL = import.meta.env.VITE_API_URL || '/api';
export const CACHE_TIME_SHORT = 2 * 60 * 1000;
export const CACHE_TIME_MEDIUM = 5 * 60 * 1000;
export const CACHE_TIME_LONG = 10 * 60 * 1000;
export const STALE_TIME_SHORT = 1 * 60 * 1000;
export const STALE_TIME_MEDIUM = 5 * 60 * 1000;
export const STALE_TIME_LONG = 10 * 60 * 1000;
export const DEFAULT_PAGE_SIZE = 12;
export const DEFAULT_PAGE = 1;
export const DEBOUNCE_SEARCH = 600;
export const STORAGE_KEY_TOKEN = 'proposal_system_token';
export const STORAGE_KEY_SIDEBAR = 'sidebarOpen';
export const PROPOSAL_STATUSES = ['Draft', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
export const SERVICE_CATEGORIES = ['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation'] as const;
export const PRIORITIES = ['Standard', 'Urgent', 'Emergency'] as const;

View file

@ -0,0 +1,9 @@
export const QUERY_KEYS = {
proposals: 'proposals',
proposal: 'proposal',
proposalLineItems: 'proposalLineItems',
customers: 'customers',
users: 'users',
dashboard: 'dashboard',
auditTrail: 'auditTrail',
} as const;

35
web/src/hooks/useAuth.ts Normal file
View file

@ -0,0 +1,35 @@
import { useSelector, useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import {
selectUser,
selectIsAuthenticated,
selectAuthLoading,
selectAuthError,
logout as logoutAction,
} from '../app/slices/authSlice';
import { toast } from 'react-toastify';
import type { RootState } from '../app/store';
export function useAuth() {
const dispatch = useDispatch();
const navigate = useNavigate();
const user = useSelector((state: RootState) => selectUser(state));
const isAuthenticated = useSelector((state: RootState) => selectIsAuthenticated(state));
const loading = useSelector((state: RootState) => selectAuthLoading(state));
const error = useSelector((state: RootState) => selectAuthError(state));
const logout = () => {
dispatch(logoutAction());
toast.info('You have been logged out');
navigate('/login');
};
return {
user,
isAuthenticated,
loading,
error,
logout,
};
}

View file

@ -0,0 +1,68 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../constants';
interface PaginatedResult<T> {
items: T[];
totalCount: number;
}
type FetchFn<T> = (params: Record<string, unknown>) => Promise<PaginatedResult<T>>;
export function usePaginatedList<T>(fetchFn: FetchFn<T>, extraParams: Record<string, unknown> = {}) {
const [rows, setRows] = useState<T[]>([]);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(DEFAULT_PAGE);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const extraKey = JSON.stringify(extraParams);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, DEBOUNCE_SEARCH);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search]);
const reload = useCallback(() => {
setLoading(true);
setErr('');
fetchFn({ search: debouncedSearch, page, pageSize, ...extraParams })
.then(({ items, totalCount }) => {
setRows(items);
setTotalCount(totalCount);
})
.catch((e: Error) => setErr(e.message || 'Failed to load'))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchFn, debouncedSearch, page, pageSize, extraKey]);
useEffect(() => {
reload();
}, [reload]);
return useMemo(
() => ({
rows,
search,
setSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
reload,
}),
[rows, search, page, pageSize, totalCount, loading, err, reload]
);
}

28
web/src/lib/api/auth.ts Normal file
View file

@ -0,0 +1,28 @@
import apiClient from './client';
export interface AuthUser {
id: string;
email: string;
displayName: string;
role: 'Dispatcher' | 'Admin' | 'SysAdmin';
token: string;
}
export interface UserProfile {
id: string;
email: string;
displayName: string;
role: 'Dispatcher' | 'Admin' | 'SysAdmin';
}
export const authApi = {
exchangeCode: async (code: string, redirectUri: string): Promise<AuthUser> => {
const res = await apiClient.post('/auth/callback', { code, redirectUri });
return res.data;
},
getMe: async (): Promise<UserProfile> => {
const res = await apiClient.get('/users/me');
return res.data;
},
};

62
web/src/lib/api/client.ts Normal file
View file

@ -0,0 +1,62 @@
import axios from 'axios';
import { API_URL, STORAGE_KEY_TOKEN } from '../../constants';
const apiClient = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
apiClient.interceptors.request.use(
(config) => {
const tokenData = window.localStorage.getItem(STORAGE_KEY_TOKEN);
if (tokenData) {
try {
const parsed = JSON.parse(tokenData);
if (parsed?.token) {
config.headers.Authorization = `Bearer ${parsed.token}`;
}
} catch {
// Invalid token data
}
}
return config;
},
(error) => Promise.reject(error)
);
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
const { status, data } = error.response;
if (status === 401) {
window.localStorage.removeItem(STORAGE_KEY_TOKEN);
window.location.href = '/login';
return Promise.reject(new Error('Session expired. Please log in again.'));
}
if (status === 403) {
return Promise.reject(new Error('You do not have permission to perform this action.'));
}
if (status === 404) {
return Promise.reject(new Error('The requested resource was not found.'));
}
const message = data?.detail || data?.title || data?.message || 'An error occurred';
return Promise.reject(new Error(message));
}
if (error.request) {
return Promise.reject(new Error('No response from server. Please check your connection.'));
}
return Promise.reject(error);
}
);
export default apiClient;

View file

@ -0,0 +1,21 @@
import apiClient from './client';
export interface Customer {
id: string;
name: string;
addresses: string[];
createdAt: string;
}
export const customersApi = {
search: async (query?: string): Promise<Customer[]> => {
const params = query ? `?query=${encodeURIComponent(query)}` : '';
const res = await apiClient.get(`/customers${params}`);
return res.data;
},
create: async (data: { name: string; addresses?: string[] }): Promise<Customer> => {
const res = await apiClient.post('/customers', data);
return res.data;
},
};

View file

@ -0,0 +1,100 @@
import apiClient from './client';
export interface CreateProposalRequest {
workOrderNumber: string;
customerName: string;
customerAddress: string;
scopeOfWork: string;
serviceCategory: string;
priority: string;
notes?: string;
}
export interface ProposalListItem {
id: string;
proposalNumber: string;
customerName: string;
workOrderNumber: string;
serviceCategory: string;
priority: string;
status: string;
totalBidAmount: number;
submittedAt: string;
submittedByName: string | null;
assignedAdminName: string | null;
}
export interface ProposalDetail {
id: string;
proposalNumber: string;
workOrderNumber: string;
customerName: string;
customerAddress: string;
scopeOfWork: string;
refinedScope: string | null;
serviceCategory: string;
priority: string;
status: string;
totalBidAmount: number;
vendorTotalCost: number | null;
notes: string;
submittedById: string;
submittedByName: string | null;
submittedAt: string;
assignedAdminId: string | null;
approvedById: string | null;
approvedAt: string | null;
sentAt: string | null;
currentRevision: number;
parentProposalId: string | null;
createdAt: string;
updatedAt: string;
}
export interface PagedResponse<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
}
export interface ProposalFilters {
search?: string;
page?: number;
pageSize?: number;
status?: string;
serviceCategory?: string;
priority?: string;
fromDate?: string;
toDate?: string;
}
export const proposalsApi = {
create: async (data: CreateProposalRequest): Promise<ProposalDetail> => {
const res = await apiClient.post('/proposals', data);
return res.data;
},
getAll: async (filters: ProposalFilters = {}): Promise<PagedResponse<ProposalListItem>> => {
const params = new URLSearchParams();
if (filters.search) params.append('search', filters.search);
if (filters.page) params.append('page', String(filters.page));
if (filters.pageSize) params.append('pageSize', String(filters.pageSize));
if (filters.status) params.append('status', filters.status);
if (filters.serviceCategory) params.append('serviceCategory', filters.serviceCategory);
if (filters.priority) params.append('priority', filters.priority);
const res = await apiClient.get(`/proposals?${params.toString()}`);
return res.data;
},
getById: async (id: string): Promise<ProposalDetail> => {
const res = await apiClient.get(`/proposals/${id}`);
return res.data;
},
uploadAttachment: async (proposalId: string, fileName: string): Promise<{ uploadUrl: string; s3Key: string }> => {
const res = await apiClient.post(`/proposals/${proposalId}/attachments?fileName=${encodeURIComponent(fileName)}`);
return res.data;
},
};

View file

@ -0,0 +1,19 @@
import { QueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
onError: (error) => {
const message = error instanceof Error ? error.message : 'An error occurred';
toast.error(message);
},
},
},
});

View file

@ -1,12 +1,28 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
function App() { import { Provider } from 'react-redux';
return <div>Proposal System</div>; import { QueryClientProvider } from '@tanstack/react-query';
} import { ThemeProvider, CssBaseline } from '@mui/material';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { store } from './app/store';
import { queryClient } from './lib/queryClient';
import { theme } from './theme';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <Provider store={store}>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<App />
</BrowserRouter>
<ToastContainer position="bottom-right" autoClose={4000} />
</ThemeProvider>
</QueryClientProvider>
</Provider>
</React.StrictMode> </React.StrictMode>
); );

View file

@ -0,0 +1,61 @@
import { useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { Box, CircularProgress, Typography } from '@mui/material';
import { setUser, setError, setLoading } from '../../app/slices/authSlice';
import { authApi } from '../../lib/api/auth';
export default function AuthCallback() {
const navigate = useNavigate();
const dispatch = useDispatch();
const [searchParams] = useSearchParams();
const processed = useRef(false);
useEffect(() => {
if (processed.current) return;
processed.current = true;
const code = searchParams.get('code');
const error = searchParams.get('error');
if (error) {
dispatch(setError(error));
navigate('/login', { replace: true });
return;
}
if (!code) {
dispatch(setError('No authorization code received'));
navigate('/login', { replace: true });
return;
}
dispatch(setLoading(true));
authApi
.exchangeCode(code, `${window.location.origin}/callback`)
.then((user) => {
dispatch(setUser(user));
navigate('/', { replace: true });
})
.catch((err) => {
dispatch(setError(err.message || 'Authentication failed'));
navigate('/login', { replace: true });
});
}, [searchParams, dispatch, navigate]);
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
}}
>
<CircularProgress />
<Typography color="text.secondary">Completing sign-in...</Typography>
</Box>
);
}

View file

@ -0,0 +1,64 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Button, Card, CardContent, Typography } from '@mui/material';
import GoogleIcon from '@mui/icons-material/Google';
import { useAuth } from '../../hooks/useAuth';
const COGNITO_DOMAIN = import.meta.env.VITE_COGNITO_DOMAIN || 'proposal-system-seahaven.auth.us-east-1.amazoncognito.com';
const CLIENT_ID = import.meta.env.VITE_COGNITO_CLIENT_ID || '';
const REDIRECT_URI = `${window.location.origin}/callback`;
function buildLoginUrl(): string {
const params = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
scope: 'openid email profile',
redirect_uri: REDIRECT_URI,
identity_provider: 'Google',
});
return `https://${COGNITO_DOMAIN}/oauth2/authorize?${params.toString()}`;
}
export default function LoginPage() {
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
useEffect(() => {
if (isAuthenticated) {
navigate('/', { replace: true });
}
}, [isAuthenticated, navigate]);
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'background.default',
}}
>
<Card sx={{ maxWidth: 400, width: '100%', mx: 2 }}>
<CardContent sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h5" gutterBottom>
Proposal System
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
Sign in with your Sea Haven Google account to continue.
</Typography>
<Button
variant="contained"
size="large"
fullWidth
startIcon={<GoogleIcon />}
href={buildLoginUrl()}
sx={{ py: 1.5 }}
>
Sign in with Google
</Button>
</CardContent>
</Card>
</Box>
);
}

View file

@ -0,0 +1,168 @@
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<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
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 Dashboard() {
const navigate = useNavigate();
const {
rows,
search,
setSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll);
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5">My Proposals</Typography>
<Button
variant="contained"
startIcon={<AddCircleIcon />}
onClick={() => navigate('/proposals/new')}
>
New Proposal
</Button>
</Box>
<Card>
<CardContent sx={{ pb: 0 }}>
<TextField
size="small"
placeholder="Search proposals..."
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ mb: 2, width: 320 }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
},
}}
/>
{err && (
<Typography color="error" sx={{ mb: 2 }}>
{err}
</Typography>
)}
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Proposal #</TableCell>
<TableCell>Customer</TableCell>
<TableCell>WO #</TableCell>
<TableCell>Category</TableCell>
<TableCell>Priority</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Bid Amount</TableCell>
<TableCell>Submitted</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<TableCell key={j}>
<Skeleton variant="text" />
</TableCell>
))}
</TableRow>
))
: rows.map((row) => (
<TableRow
key={row.id}
hover
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/proposals/${row.id}`)}
>
<TableCell sx={{ fontWeight: 600 }}>{row.proposalNumber}</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.workOrderNumber}</TableCell>
<TableCell>{row.serviceCategory}</TableCell>
<TableCell>{row.priority}</TableCell>
<TableCell>
<Chip
label={row.status}
size="small"
color={STATUS_COLORS[row.status] || 'default'}
/>
</TableCell>
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
<TableCell>{formatDate(row.submittedAt)}</TableCell>
</TableRow>
))}
{!loading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">
No proposals found. Create your first one!
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page - 1}
onPageChange={(_, newPage) => setPage(newPage + 1)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))}
rowsPerPageOptions={[12, 24, 48]}
/>
</CardContent>
</Card>
</Box>
);
}

View file

@ -0,0 +1,178 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Box,
Typography,
Card,
CardContent,
Grid,
Chip,
Divider,
Button,
Skeleton,
Stepper,
Step,
StepLabel,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
const STATUS_ORDER = ['Draft', 'InReview', 'Approved', 'Sent'];
const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
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 formatDateTime(iso: string | null): string {
if (!iso) return '-';
return new Date(iso).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
<Typography variant="body1">{value || '-'}</Typography>
</Grid>
);
}
export default function ProposalDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({
queryKey: ['proposals', id],
queryFn: () => proposalsApi.getById(id!),
enabled: !!id,
});
if (isLoading) {
return (
<Box>
<Skeleton variant="text" width={300} height={40} />
<Skeleton variant="rectangular" height={400} sx={{ mt: 2, borderRadius: 1 }} />
</Box>
);
}
if (error || !proposal) {
return (
<Box>
<Typography color="error">
{error instanceof Error ? error.message : 'Proposal not found'}
</Typography>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/')} sx={{ mt: 2 }}>
Back to Dashboard
</Button>
</Box>
);
}
const activeStep = STATUS_ORDER.indexOf(proposal.status);
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/')}>
Back
</Button>
<Typography variant="h5" sx={{ flexGrow: 1 }}>
{proposal.proposalNumber}
</Typography>
<Chip
label={proposal.status}
color={STATUS_COLORS[proposal.status] || 'default'}
size="medium"
/>
</Box>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 2 }}>
Status Timeline
</Typography>
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
{STATUS_ORDER.map((label) => (
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 2 }}>
Proposal Details
</Typography>
<Grid container spacing={2}>
<InfoRow label="Work Order #" value={proposal.workOrderNumber} />
<InfoRow label="Customer" value={proposal.customerName} />
<InfoRow label="Address" value={proposal.customerAddress} />
<InfoRow label="Category" value={proposal.serviceCategory} />
<InfoRow label="Priority" value={proposal.priority} />
<InfoRow label="Revision" value={`#${proposal.currentRevision}`} />
<InfoRow label="Submitted By" value={proposal.submittedByName} />
<InfoRow label="Submitted" value={formatDateTime(proposal.submittedAt)} />
<InfoRow label="Bid Amount" value={formatCurrency(proposal.totalBidAmount)} />
</Grid>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary">
Scope of Work
</Typography>
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap', mt: 0.5 }}>
{proposal.refinedScope || proposal.scopeOfWork}
</Typography>
{proposal.notes && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary">
Notes
</Typography>
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap', mt: 0.5 }}>
{proposal.notes}
</Typography>
</>
)}
</CardContent>
</Card>
{(proposal.approvedAt || proposal.sentAt) && (
<Card>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 2 }}>
Approval & Delivery
</Typography>
<Grid container spacing={2}>
<InfoRow label="Approved At" value={formatDateTime(proposal.approvedAt)} />
<InfoRow label="Sent At" value={formatDateTime(proposal.sentAt)} />
{proposal.vendorTotalCost !== null && (
<InfoRow label="Vendor Cost" value={formatCurrency(proposal.vendorTotalCost)} />
)}
</Grid>
</CardContent>
</Card>
)}
</Box>
);
}

View file

@ -0,0 +1,274 @@
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import {
Box,
Typography,
Card,
CardContent,
TextField,
Button,
Grid,
MenuItem,
Autocomplete,
CircularProgress,
} from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import { toast } from 'react-toastify';
import { proposalsApi, type CreateProposalRequest } from '../../../lib/api/proposals';
import { customersApi, type Customer } from '../../../lib/api/customers';
import { SERVICE_CATEGORIES, PRIORITIES } from '../../../constants';
import { queryClient } from '../../../lib/queryClient';
export default function ProposalFormPage() {
const navigate = useNavigate();
const [form, setForm] = useState<CreateProposalRequest>({
workOrderNumber: '',
customerName: '',
customerAddress: '',
scopeOfWork: '',
serviceCategory: '',
priority: 'Standard',
notes: '',
});
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
const [addresses, setAddresses] = useState<string[]>([]);
const [vendorFile, setVendorFile] = useState<File | null>(null);
const handleChange = (field: keyof CreateProposalRequest, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const searchCustomers = useCallback(async (query: string) => {
if (query.length < 2) return;
setCustomerLoading(true);
try {
const results = await customersApi.search(query);
setCustomers(results);
} catch {
setCustomers([]);
} finally {
setCustomerLoading(false);
}
}, []);
const handleCustomerSelect = (_: unknown, value: Customer | string | null) => {
if (!value) {
setForm((prev) => ({ ...prev, customerName: '', customerAddress: '' }));
setAddresses([]);
return;
}
if (typeof value === 'string') {
setForm((prev) => ({ ...prev, customerName: value, customerAddress: '' }));
setAddresses([]);
} else {
setForm((prev) => ({ ...prev, customerName: value.name, customerAddress: value.addresses[0] || '' }));
setAddresses(value.addresses);
}
};
const createMutation = useMutation({
mutationFn: async () => {
const proposal = await proposalsApi.create(form);
if (vendorFile) {
const { uploadUrl } = await proposalsApi.uploadAttachment(proposal.id, vendorFile.name);
await fetch(uploadUrl, {
method: 'PUT',
body: vendorFile,
headers: { 'Content-Type': vendorFile.type || 'application/pdf' },
});
}
return proposal;
},
onSuccess: (proposal) => {
queryClient.invalidateQueries({ queryKey: ['proposals'] });
toast.success(`Proposal ${proposal.proposalNumber} submitted`);
navigate(`/proposals/${proposal.id}`);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate();
};
const isValid =
form.workOrderNumber.trim() &&
form.customerName.trim() &&
form.customerAddress.trim() &&
form.scopeOfWork.trim() &&
form.serviceCategory;
return (
<Box>
<Typography variant="h5" sx={{ mb: 3 }}>
New Proposal Request
</Typography>
<Card>
<CardContent>
<Box component="form" onSubmit={handleSubmit}>
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Work Order #"
required
fullWidth
value={form.workOrderNumber}
onChange={(e) => handleChange('workOrderNumber', e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Autocomplete
freeSolo
options={customers}
getOptionLabel={(opt) => (typeof opt === 'string' ? opt : opt.name)}
loading={customerLoading}
onInputChange={(_, value) => searchCustomers(value)}
onChange={handleCustomerSelect}
renderInput={(params) => (
<TextField
{...params}
label="Customer"
required
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{customerLoading && <CircularProgress size={20} />}
{params.InputProps.endAdornment}
</>
),
},
}}
/>
)}
/>
</Grid>
<Grid size={{ xs: 12 }}>
{addresses.length > 1 ? (
<TextField
label="Customer Address"
required
fullWidth
select
value={form.customerAddress}
onChange={(e) => handleChange('customerAddress', e.target.value)}
>
{addresses.map((addr) => (
<MenuItem key={addr} value={addr}>
{addr}
</MenuItem>
))}
</TextField>
) : (
<TextField
label="Customer Address"
required
fullWidth
value={form.customerAddress}
onChange={(e) => handleChange('customerAddress', e.target.value)}
/>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Service Category"
required
fullWidth
select
value={form.serviceCategory}
onChange={(e) => handleChange('serviceCategory', e.target.value)}
>
{SERVICE_CATEGORIES.map((cat) => (
<MenuItem key={cat} value={cat}>
{cat}
</MenuItem>
))}
</TextField>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Priority"
required
fullWidth
select
value={form.priority}
onChange={(e) => handleChange('priority', e.target.value)}
>
{PRIORITIES.map((p) => (
<MenuItem key={p} value={p}>
{p}
</MenuItem>
))}
</TextField>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
label="Scope of Work"
required
fullWidth
multiline
minRows={4}
value={form.scopeOfWork}
onChange={(e) => handleChange('scopeOfWork', e.target.value)}
placeholder="Describe the work needed..."
/>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
label="Notes (optional)"
fullWidth
multiline
minRows={2}
value={form.notes}
onChange={(e) => handleChange('notes', e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12 }}>
<Button variant="outlined" component="label">
{vendorFile ? vendorFile.name : 'Attach Vendor Proposal (PDF)'}
<input
type="file"
hidden
accept=".pdf"
onChange={(e) => setVendorFile(e.target.files?.[0] || null)}
/>
</Button>
</Grid>
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button variant="text" onClick={() => navigate(-1)}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
startIcon={<SendIcon />}
disabled={!isValid || createMutation.isPending}
>
{createMutation.isPending ? 'Submitting...' : 'Submit Proposal'}
</Button>
</Box>
</Grid>
</Grid>
</Box>
</CardContent>
</Card>
</Box>
);
}

View file

@ -0,0 +1,5 @@
import Dashboard from '../../dashboard/Dashboard';
export default function ProposalListPage() {
return <Dashboard />;
}

55
web/src/theme.ts Normal file
View file

@ -0,0 +1,55 @@
import { createTheme } from '@mui/material/styles';
export const theme = createTheme({
palette: {
primary: {
main: '#1565c0',
light: '#5e92f3',
dark: '#003c8f',
},
secondary: {
main: '#f57c00',
light: '#ffad42',
dark: '#bb4d00',
},
background: {
default: '#f5f5f5',
paper: '#ffffff',
},
},
typography: {
fontFamily: '"Nunito Sans", "Helvetica", "Arial", sans-serif',
h4: { fontWeight: 700 },
h5: { fontWeight: 700 },
h6: { fontWeight: 600 },
},
shape: {
borderRadius: 8,
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
fontWeight: 600,
},
},
},
MuiAppBar: {
defaultProps: {
elevation: 1,
},
},
MuiCard: {
defaultProps: {
elevation: 0,
},
styleOverrides: {
root: {
border: '1px solid',
borderColor: '#e0e0e0',
},
},
},
},
});

1
web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -5,5 +5,11 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173, port: 5173,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
},
},
}, },
}); });