mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 07:43: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": {
|
"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
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 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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
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()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue