mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 05:23:14 +00:00
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:
parent
30e1da2c09
commit
378d0cc466
28 changed files with 4639 additions and 11 deletions
3121
web/package-lock.json
generated
Normal file
3121
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,12 +11,16 @@
|
|||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/material": "^7.0.0",
|
||||
"@tanstack/react-query": "^5.60.0",
|
||||
"axios": "^1.7.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.0.0"
|
||||
"@mui/icons-material": "^7.3.1",
|
||||
"@mui/material": "^7.3.1",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"axios": "^1.16.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": {
|
||||
"@types/react": "^19.0.0",
|
||||
|
|
|
|||
57
web/src/App.tsx
Normal file
57
web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
web/src/app/slices/authSlice.ts
Normal file
87
web/src/app/slices/authSlice.ts
Normal 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;
|
||||
29
web/src/app/slices/uiSlice.ts
Normal file
29
web/src/app/slices/uiSlice.ts
Normal 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
19
web/src/app/store.ts
Normal 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;
|
||||
12
web/src/components/ProtectedRoute.tsx
Normal file
12
web/src/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
71
web/src/components/Sidebar.tsx
Normal file
71
web/src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
web/src/components/Topbar.tsx
Normal file
37
web/src/components/Topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
web/src/constants/index.ts
Normal file
21
web/src/constants/index.ts
Normal 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;
|
||||
9
web/src/constants/queryKeys.ts
Normal file
9
web/src/constants/queryKeys.ts
Normal 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
35
web/src/hooks/useAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
68
web/src/hooks/usePaginatedList.ts
Normal file
68
web/src/hooks/usePaginatedList.ts
Normal 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
28
web/src/lib/api/auth.ts
Normal 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
62
web/src/lib/api/client.ts
Normal 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;
|
||||
21
web/src/lib/api/customers.ts
Normal file
21
web/src/lib/api/customers.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
100
web/src/lib/api/proposals.ts
Normal file
100
web/src/lib/api/proposals.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
19
web/src/lib/queryClient.ts
Normal file
19
web/src/lib/queryClient.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,12 +1,28 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
function App() {
|
||||
return <div>Proposal System</div>;
|
||||
}
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
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(
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
61
web/src/pages/auth/AuthCallback.tsx
Normal file
61
web/src/pages/auth/AuthCallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
web/src/pages/auth/LoginPage.tsx
Normal file
64
web/src/pages/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
web/src/pages/dashboard/Dashboard.tsx
Normal file
168
web/src/pages/dashboard/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
web/src/pages/proposals/detail/ProposalDetailPage.tsx
Normal file
178
web/src/pages/proposals/detail/ProposalDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
web/src/pages/proposals/form/ProposalFormPage.tsx
Normal file
274
web/src/pages/proposals/form/ProposalFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
web/src/pages/proposals/list/ProposalListPage.tsx
Normal file
5
web/src/pages/proposals/list/ProposalListPage.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import Dashboard from '../../dashboard/Dashboard';
|
||||
|
||||
export default function ProposalListPage() {
|
||||
return <Dashboard />;
|
||||
}
|
||||
55
web/src/theme.ts
Normal file
55
web/src/theme.ts
Normal 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
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -5,5 +5,11 @@ export default defineConfig({
|
|||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue