style(web): prettier format pass (mechanical)

npx prettier --write . with the new .prettierrc (singleQuote,
printWidth 100). No functional changes — enforced by format:check in
CI from this PR on.
This commit is contained in:
Adam Moussa 2026-07-13 20:14:47 -04:00
parent 191f710752
commit 6acfdabc8c
No known key found for this signature in database
45 changed files with 915 additions and 370 deletions

View file

@ -15,9 +15,12 @@ Drive with Playwright resolved from the SHOC checkout (this repo doesn't ship it
using the cached headless chromium: using the cached headless chromium:
```js ```js
const { chromium } = require('/Users/adammoussa/Documents/repositories/seahaven/shoc-frontend-new/node_modules/playwright'); const {
chromium,
} = require('/Users/adammoussa/Documents/repositories/seahaven/shoc-frontend-new/node_modules/playwright');
const browser = await chromium.launch({ const browser = await chromium.launch({
executablePath: '/Users/adammoussa/Library/Caches/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-mac-arm64/chrome-headless-shell', executablePath:
'/Users/adammoussa/Library/Caches/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-mac-arm64/chrome-headless-shell',
}); });
``` ```
@ -29,6 +32,7 @@ await ctx.addInitScript((v) => sessionStorage.setItem('proposal_system_token', v
``` ```
Gotchas: Gotchas:
- `addInitScript` re-runs on every document load — a full-page reload re-seeds the - `addInitScript` re-runs on every document load — a full-page reload re-seeds the
token, so "logged-out then reload" probes need a fresh un-seeded context. token, so "logged-out then reload" probes need a fresh un-seeded context.
- Dev-login buttons on /login call the real API (`/auth/dev-login`) — they fail - Dev-login buttons on /login call the real API (`/auth/dev-login`) — they fail

View file

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />

View file

@ -38,9 +38,26 @@ export default function App() {
element={ element={
<ProtectedRoute> <ProtectedRoute>
{/* SHOC shell composition (ADR 0003): sidebar column + [topbar / scrolling main] */} {/* SHOC shell composition (ADR 0003): sidebar column + [topbar / scrolling main] */}
<Box sx={{ display: 'flex', height: '100vh', width: '100%', overflow: 'hidden', backgroundColor: 'var(--background)' }}> <Box
sx={{
display: 'flex',
height: '100vh',
width: '100%',
overflow: 'hidden',
backgroundColor: 'var(--background)',
}}
>
<Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} /> <Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} />
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, height: '100vh', overflow: 'hidden' }}> <Box
sx={{
display: 'flex',
flexDirection: 'column',
flex: 1,
minWidth: 0,
height: '100vh',
overflow: 'hidden',
}}
>
<Topbar onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} /> <Topbar onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} />
<Box <Box
component="main" component="main"
@ -52,41 +69,84 @@ export default function App() {
}} }}
> >
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/proposals" element={<ProposalListPage />} /> <Route path="/proposals" element={<ProposalListPage />} />
<Route path="/proposals/new" element={<ProposalFormPage />} /> <Route path="/proposals/new" element={<ProposalFormPage />} />
<Route path="/proposals/:id" element={<ProposalDetailPage />} /> <Route path="/proposals/:id" element={<ProposalDetailPage />} />
{/* Admin Routes */} {/* Admin Routes */}
<Route path="/admin" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard defaultStatus="InReview" /></RoleGuard>} /> <Route
<Route path="/admin/proposals" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} /> path="/admin"
<Route path="/admin/proposals/:id" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminWorkspace /></RoleGuard>} /> element={
<Route path="/admin/customers" element={<RoleGuard roles={['Admin', 'SysAdmin']}><CustomerManagementPage /></RoleGuard>} /> <RoleGuard roles={['Admin', 'SysAdmin']}>
<Route path="/admin/pricing-library" element={<RoleGuard roles={['Admin', 'SysAdmin']}><PricingLibraryPage /></RoleGuard>} /> <AdminDashboard defaultStatus="InReview" />
<Route path="/admin/users" element={ </RoleGuard>
<RoleGuard roles={['SysAdmin']}> }
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}> />
<Card sx={{ maxWidth: 520, p: 4, textAlign: 'center' }}> <Route
<CardContent> path="/admin/proposals"
<PeopleIcon sx={{ fontSize: 48, color: 'var(--color-text-muted)', mb: 2 }} /> element={
<Typography variant="h5" sx={{ mb: 1 }}>User Management</Typography> <RoleGuard roles={['Admin', 'SysAdmin']}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}> <AdminDashboard />
Users and roles are currently managed in AWS Cognito. Contact the system administrator to update access. </RoleGuard>
</Typography> }
<Button />
variant="outlined" <Route
size="large" path="/admin/proposals/:id"
href="https://us-east-1.console.aws.amazon.com/cognito/v2/idp/user-pools?region=us-east-1" element={
target="_blank" <RoleGuard roles={['Admin', 'SysAdmin']}>
rel="noopener noreferrer" <AdminWorkspace />
> </RoleGuard>
Manage in Cognito }
</Button> />
</CardContent> <Route
</Card> path="/admin/customers"
</Box> element={
</RoleGuard> <RoleGuard roles={['Admin', 'SysAdmin']}>
} /> <CustomerManagementPage />
</RoleGuard>
}
/>
<Route
path="/admin/pricing-library"
element={
<RoleGuard roles={['Admin', 'SysAdmin']}>
<PricingLibraryPage />
</RoleGuard>
}
/>
<Route
path="/admin/users"
element={
<RoleGuard roles={['SysAdmin']}>
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
<Card sx={{ maxWidth: 520, p: 4, textAlign: 'center' }}>
<CardContent>
<PeopleIcon
sx={{ fontSize: 48, color: 'var(--color-text-muted)', mb: 2 }}
/>
<Typography variant="h5" sx={{ mb: 1 }}>
User Management
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Users and roles are currently managed in AWS Cognito. Contact the
system administrator to update access.
</Typography>
<Button
variant="outlined"
size="large"
href="https://us-east-1.console.aws.amazon.com/cognito/v2/idp/user-pools?region=us-east-1"
target="_blank"
rel="noopener noreferrer"
>
Manage in Cognito
</Button>
</CardContent>
</Card>
</Box>
</RoleGuard>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View file

@ -24,7 +24,17 @@ export default class ErrorBoundary extends React.Component<Props, State> {
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', gap: 2, p: 4 }}> <Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
p: 4,
}}
>
<Typography variant="h5">Something went wrong</Typography> <Typography variant="h5">Something went wrong</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 480, textAlign: 'center' }}> <Typography color="text.secondary" sx={{ maxWidth: 480, textAlign: 'center' }}>
An unexpected error occurred. Please try refreshing the page. An unexpected error occurred. Please try refreshing the page.

View file

@ -7,7 +7,9 @@ export default function ProtectedRoute({ children }: { children: React.ReactNode
if (isLoading) { if (isLoading) {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}> <Box
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}
>
<CircularProgress /> <CircularProgress />
</Box> </Box>
); );

View file

@ -35,13 +35,9 @@ const adminNav: NavEntry[] = [
{ label: 'Pricing Library', path: '/admin/pricing-library', icon: BookOpen }, { label: 'Pricing Library', path: '/admin/pricing-library', icon: BookOpen },
]; ];
const sysadminNav: NavEntry[] = [ const sysadminNav: NavEntry[] = [{ label: 'Users', path: '/admin/users', icon: Users }];
{ label: 'Users', path: '/admin/users', icon: Users },
];
const ALL_NAV_PATHS = new Set( const ALL_NAV_PATHS = new Set([...dispatcherNav, ...adminNav, ...sysadminNav].map((n) => n.path));
[...dispatcherNav, ...adminNav, ...sysadminNav].map(n => n.path)
);
function isNavActive(pathname: string, itemPath: string): boolean { function isNavActive(pathname: string, itemPath: string): boolean {
if (pathname === itemPath) return true; if (pathname === itemPath) return true;
@ -115,7 +111,10 @@ function NavItem({
)} )}
<Icon size={19} /> <Icon size={19} />
{!collapsed && ( {!collapsed && (
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <Box
component="span"
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{item.label} {item.label}
</Box> </Box>
)} )}
@ -168,7 +167,9 @@ export default function Sidebar({
); );
const groupDivider = ( const groupDivider = (
<Box sx={{ height: '1px', mx: '14px', my: '10px', backgroundColor: 'var(--color-sidebar-border)' }} /> <Box
sx={{ height: '1px', mx: '14px', my: '10px', backgroundColor: 'var(--color-sidebar-border)' }}
/>
); );
const renderGroup = (label: string, items: NavEntry[], first: boolean) => ( const renderGroup = (label: string, items: NavEntry[], first: boolean) => (

View file

@ -7,7 +7,12 @@ export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => voi
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const initials = user?.displayName const initials = user?.displayName
? user.displayName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) ? user.displayName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
: 'U'; : 'U';
const isDevMode = !import.meta.env.VITE_COGNITO_DOMAIN; const isDevMode = !import.meta.env.VITE_COGNITO_DOMAIN;
@ -24,7 +29,16 @@ export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => voi
boxShadow: 'var(--color-header-shadow)', boxShadow: 'var(--color-header-shadow)',
}} }}
> >
<Toolbar disableGutters sx={{ height: 'var(--spacing-topbar)', minHeight: 'var(--spacing-topbar)', gap: '18px', pl: '16px', pr: '22px' }}> <Toolbar
disableGutters
sx={{
height: 'var(--spacing-topbar)',
minHeight: 'var(--spacing-topbar)',
gap: '18px',
pl: '16px',
pr: '22px',
}}
>
<IconButton <IconButton
onClick={onToggleSidebar} onClick={onToggleSidebar}
aria-label="Toggle sidebar menu" aria-label="Toggle sidebar menu"
@ -88,10 +102,14 @@ export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => voi
{initials} {initials}
</Avatar> </Avatar>
<Box sx={{ display: 'flex', flexDirection: 'column', lineHeight: 1.2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', lineHeight: 1.2 }}>
<Typography sx={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-header-text)' }}> <Typography
sx={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-header-text)' }}
>
{user.displayName} {user.displayName}
</Typography> </Typography>
<Typography sx={{ fontSize: 'var(--text-xs)', color: 'var(--color-header-text-muted)' }}> <Typography
sx={{ fontSize: 'var(--text-xs)', color: 'var(--color-header-text-muted)' }}
>
{user.role} {user.role}
</Typography> </Typography>
</Box> </Box>
@ -101,7 +119,10 @@ export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => voi
aria-label="Log out" aria-label="Log out"
sx={{ sx={{
color: 'var(--color-header-text-muted)', color: 'var(--color-header-text-muted)',
'&:hover': { color: 'var(--color-header-text)', backgroundColor: 'var(--color-header-surface-hover)' }, '&:hover': {
color: 'var(--color-header-text)',
backgroundColor: 'var(--color-header-surface-hover)',
},
}} }}
> >
<LogOut size={16} /> <LogOut size={16} />

View file

@ -87,7 +87,13 @@ describe('RoleGuard', () => {
it('QA-C5: redirects to / when user role is not in allowed list (dispatcher cannot access admin)', () => { it('QA-C5: redirects to / when user role is not in allowed list (dispatcher cannot access admin)', () => {
mockUseAuth.mockReturnValue({ mockUseAuth.mockReturnValue({
isAuthenticated: true, isAuthenticated: true,
user: { id: '2', email: 'dispatch@test.com', displayName: 'Dispatcher', role: 'Dispatcher', token: 'tok' }, user: {
id: '2',
email: 'dispatch@test.com',
displayName: 'Dispatcher',
role: 'Dispatcher',
token: 'tok',
},
isLoading: false, isLoading: false,
}); });
@ -121,7 +127,13 @@ describe('RoleGuard', () => {
it('QA-C5: SysAdmin can access sysadmin-only routes', () => { it('QA-C5: SysAdmin can access sysadmin-only routes', () => {
mockUseAuth.mockReturnValue({ mockUseAuth.mockReturnValue({
isAuthenticated: true, isAuthenticated: true,
user: { id: '3', email: 'sysadmin@test.com', displayName: 'SysAdmin', role: 'SysAdmin', token: 'tok' }, user: {
id: '3',
email: 'sysadmin@test.com',
displayName: 'SysAdmin',
role: 'SysAdmin',
token: 'tok',
},
isLoading: false, isLoading: false,
}); });

View file

@ -125,11 +125,14 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2"> <Typography variant="subtitle2">Line Items ({items.length})</Typography>
Line Items ({items.length})
</Typography>
{!disabled && ( {!disabled && (
<Button size="small" variant={items.length === 0 ? 'contained' : 'outlined'} startIcon={<AddIcon />} onClick={addItem}> <Button
size="small"
variant={items.length === 0 ? 'contained' : 'outlined'}
startIcon={<AddIcon />}
onClick={addItem}
>
Add Line Item Add Line Item
</Button> </Button>
)} )}
@ -155,11 +158,21 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
<TableCell> <TableCell>
{!disabled && ( {!disabled && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}> <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton size="small" onClick={() => moveItem(index, -1)} disabled={index === 0} aria-label={`Move item ${index + 1} up`}> <IconButton
size="small"
onClick={() => moveItem(index, -1)}
disabled={index === 0}
aria-label={`Move item ${index + 1} up`}
>
<ArrowUpwardIcon sx={{ fontSize: 14 }} /> <ArrowUpwardIcon sx={{ fontSize: 14 }} />
</IconButton> </IconButton>
<Typography variant="caption">{index + 1}</Typography> <Typography variant="caption">{index + 1}</Typography>
<IconButton size="small" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1} aria-label={`Move item ${index + 1} down`}> <IconButton
size="small"
onClick={() => moveItem(index, 1)}
disabled={index === items.length - 1}
aria-label={`Move item ${index + 1} down`}
>
<ArrowDownwardIcon sx={{ fontSize: 14 }} /> <ArrowDownwardIcon sx={{ fontSize: 14 }} />
</IconButton> </IconButton>
</Box> </Box>
@ -200,7 +213,9 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
fullWidth fullWidth
> >
{UNITS.map((u) => ( {UNITS.map((u) => (
<MenuItem key={u} value={u}>{u}</MenuItem> <MenuItem key={u} value={u}>
{u}
</MenuItem>
))} ))}
</TextField> </TextField>
</TableCell> </TableCell>
@ -209,7 +224,9 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
size="small" size="small"
type="number" type="number"
value={item.unitPrice ?? ''} value={item.unitPrice ?? ''}
onChange={(e) => updateItem(index, 'unitPrice', parseFloat(e.target.value) || 0)} onChange={(e) =>
updateItem(index, 'unitPrice', parseFloat(e.target.value) || 0)
}
disabled={disabled || item.pricingMode === 'TotalPrice'} disabled={disabled || item.pricingMode === 'TotalPrice'}
variant="standard" variant="standard"
slotProps={{ htmlInput: { min: 0, step: 0.01 } }} slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
@ -220,7 +237,9 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
size="small" size="small"
type="number" type="number"
value={item.totalPrice} value={item.totalPrice}
onChange={(e) => updateItem(index, 'totalPrice', parseFloat(e.target.value) || 0)} onChange={(e) =>
updateItem(index, 'totalPrice', parseFloat(e.target.value) || 0)
}
disabled={disabled || item.pricingMode === 'UnitPrice'} disabled={disabled || item.pricingMode === 'UnitPrice'}
variant="standard" variant="standard"
slotProps={{ htmlInput: { min: 0, step: 0.01 } }} slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
@ -272,7 +291,12 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1, pr: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1, pr: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Proposal Total: {subtotal === 0 ? 'Pending pricing' : new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(subtotal)} Proposal Total:{' '}
{subtotal === 0
? 'Pending pricing'
: new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
subtotal,
)}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>

View file

@ -20,13 +20,19 @@ interface SimilarProposalsPanelProps {
disabled?: boolean; disabled?: boolean;
} }
export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) { export default function SimilarProposalsPanel({
proposalId,
onPullLineItem,
disabled,
}: SimilarProposalsPanelProps) {
const { data: similar, isLoading } = useSimilarProposals(proposalId); const { data: similar, isLoading } = useSimilarProposals(proposalId);
if (isLoading) { if (isLoading) {
return ( return (
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Similar Proposals</Typography> <Typography variant="subtitle2" sx={{ mb: 1 }}>
Similar Proposals
</Typography>
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} variant="rectangular" height={40} sx={{ mb: 1, borderRadius: 1 }} /> <Skeleton key={i} variant="rectangular" height={40} sx={{ mb: 1, borderRadius: 1 }} />
))} ))}
@ -74,7 +80,9 @@ export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disa
}} }}
> >
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" noWrap>{li.description}</Typography> <Typography variant="caption" noWrap>
{li.description}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{li.quantity} {li.unit} @ {formatCurrency(li.totalPrice)} {li.quantity} {li.unit} @ {formatCurrency(li.totalPrice)}
</Typography> </Typography>

View file

@ -16,19 +16,27 @@ export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
if (!vendors || vendors.length === 0) { if (!vendors || vendors.length === 0) {
return ( return (
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Vendor Data</Typography> <Typography variant="subtitle2" sx={{ mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">No vendor proposals attached</Typography> Vendor Data
</Typography>
<Typography variant="body2" color="text.secondary">
No vendor proposals attached
</Typography>
</Box> </Box>
); );
} }
return ( return (
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Vendor Data</Typography> <Typography variant="subtitle2" sx={{ mb: 1 }}>
Vendor Data
</Typography>
{vendors.map((vp) => ( {vendors.map((vp) => (
<Box key={vp.id} sx={{ mb: 2 }}> <Box key={vp.id} sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{vp.vendorName}</Typography> <Typography variant="body2" sx={{ fontWeight: 600 }}>
{vp.vendorName}
</Typography>
<Chip <Chip
label={vp.processingStatus} label={vp.processingStatus}
size="small" size="small"
@ -36,7 +44,9 @@ export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
variant="outlined" variant="outlined"
/> />
</Box> </Box>
<Typography variant="caption" color="text.secondary">{vp.fileName}</Typography> <Typography variant="caption" color="text.secondary">
{vp.fileName}
</Typography>
{vp.totalVendorCost > 0 && ( {vp.totalVendorCost > 0 && (
<Typography variant="body2" sx={{ mt: 0.5, fontWeight: 600 }}> <Typography variant="body2" sx={{ mt: 0.5, fontWeight: 600 }}>

View file

@ -18,7 +18,14 @@ export const STORAGE_KEY_TOKEN = 'proposal_system_token';
export const STORAGE_KEY_SIDEBAR = 'sidebarOpen'; export const STORAGE_KEY_SIDEBAR = 'sidebarOpen';
export const PROPOSAL_STATUSES = ['Draft', 'InReview', 'Approved', 'Sent', 'Revised'] as const; export const PROPOSAL_STATUSES = ['Draft', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
export const SERVICE_CATEGORIES = ['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation', 'Other'] as const; export const SERVICE_CATEGORIES = [
'HVAC',
'Plumbing',
'Electrical',
'General',
'Renovation',
'Other',
] as const;
export const PRIORITIES = ['Standard', 'Urgent', 'Emergency'] as const; export const PRIORITIES = ['Standard', 'Urgent', 'Emergency'] as const;
export const STATUS_LABELS: Record<string, string> = { export const STATUS_LABELS: Record<string, string> = {
@ -30,7 +37,10 @@ export const STATUS_LABELS: Record<string, string> = {
}; };
// Sea Haven Ops status chips — flat fill + thin border + bold label (tokens/base.css). // Sea Haven Ops status chips — flat fill + thin border + bold label (tokens/base.css).
export const STATUS_CHIP_STYLES: Record<string, { bgcolor: string; color: string; border: string }> = { export const STATUS_CHIP_STYLES: Record<
string,
{ bgcolor: string; color: string; border: string }
> = {
Draft: { bgcolor: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB' }, Draft: { bgcolor: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB' },
InReview: { bgcolor: '#E0F2FE', color: '#075985', border: '1px solid #7DD3FC' }, InReview: { bgcolor: '#E0F2FE', color: '#075985', border: '1px solid #7DD3FC' },
Approved: { bgcolor: '#DCFCE7', color: '#166534', border: '1px solid #86EFAC' }, Approved: { bgcolor: '#DCFCE7', color: '#166534', border: '1px solid #86EFAC' },
@ -47,7 +57,10 @@ export const PRIORITY_LABELS: Record<string, string> = {
Emergency: 'Emergency', Emergency: 'Emergency',
}; };
export const PRIORITY_CHIP_STYLES: Record<string, { bgcolor: string; color: string; border: string }> = { export const PRIORITY_CHIP_STYLES: Record<
string,
{ bgcolor: string; color: string; border: string }
> = {
Standard: { bgcolor: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB' }, Standard: { bgcolor: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB' },
Urgent: { bgcolor: '#FFEDD5', color: '#9A3412', border: '1px solid #FED7AA' }, Urgent: { bgcolor: '#FFEDD5', color: '#9A3412', border: '1px solid #FED7AA' },
Emergency: { bgcolor: '#FEE2E2', color: '#991B1B', border: '1px solid #FECACA' }, Emergency: { bgcolor: '#FEE2E2', color: '#991B1B', border: '1px solid #FECACA' },

View file

@ -82,9 +82,7 @@ describe('useApproveProposal', () => {
}); });
it('toasts the failure and skips invalidation when approval is rejected', async () => { it('toasts the failure and skips invalidation when approval is rejected', async () => {
vi.mocked(adminApi.approveProposal).mockRejectedValue( vi.mocked(adminApi.approveProposal).mockRejectedValue(new Error('proposal is not in review'));
new Error('proposal is not in review')
);
const { queryClient, wrapper } = createQueryHarness(); const { queryClient, wrapper } = createQueryHarness();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');

View file

@ -6,11 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { createQueryHarness } from './hookTestUtils'; import { createQueryHarness } from './hookTestUtils';
import { import { customersKeys, useCreateCustomer, useUpdateCustomer } from '../customers/use-cases';
customersKeys,
useCreateCustomer,
useUpdateCustomer,
} from '../customers/use-cases';
import { customersApi } from '../customers/api'; import { customersApi } from '../customers/api';
import type { Customer } from '../customers/types'; import type { Customer } from '../customers/types';

View file

@ -5,13 +5,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { createQueryHarness } from './hookTestUtils'; import { createQueryHarness } from './hookTestUtils';
import { import { proposalsKeys, useProposals, useCreateProposal } from '../proposals/use-cases';
proposalsKeys,
useProposals,
useCreateProposal,
} from '../proposals/use-cases';
import { proposalsApi } from '../proposals/api'; import { proposalsApi } from '../proposals/api';
import type { ProposalDetail, ProposalFilters, ProposalListItem, PagedResponse } from '../proposals/types'; import type {
ProposalDetail,
ProposalFilters,
ProposalListItem,
PagedResponse,
} from '../proposals/types';
vi.mock('../proposals/api', () => ({ vi.mock('../proposals/api', () => ({
proposalsApi: { proposalsApi: {

View file

@ -40,15 +40,15 @@ describe('createProposalFormSchema', () => {
}); });
it('requires customCategory when serviceCategory is Other', () => { it('requires customCategory when serviceCategory is Other', () => {
expect( expect(createProposalFormSchema.safeParse({ ...valid, serviceCategory: 'Other' }).success).toBe(
createProposalFormSchema.safeParse({ ...valid, serviceCategory: 'Other' }).success false,
).toBe(false); );
expect( expect(
createProposalFormSchema.safeParse({ createProposalFormSchema.safeParse({
...valid, ...valid,
serviceCategory: 'Other', serviceCategory: 'Other',
customCategory: 'Roofing', customCategory: 'Roofing',
}).success }).success,
).toBe(true); ).toBe(true);
}); });
@ -74,19 +74,21 @@ describe('customerFormSchema', () => {
it('rejects a malformed email but accepts a valid one', () => { it('rejects a malformed email but accepts a valid one', () => {
expect(customerFormSchema.safeParse({ ...valid, contactEmail: 'nope' }).success).toBe(false); expect(customerFormSchema.safeParse({ ...valid, contactEmail: 'nope' }).success).toBe(false);
expect( expect(customerFormSchema.safeParse({ ...valid, contactEmail: 'a@b.com' }).success).toBe(true);
customerFormSchema.safeParse({ ...valid, contactEmail: 'a@b.com' }).success
).toBe(true);
}); });
it('maps to addresses[] preserving extra existing addresses', () => { it('maps to addresses[] preserving extra existing addresses', () => {
const request = toCustomerRequest( const request = toCustomerRequest({ name: ' Acme ', address: ' 2 New St ', contactEmail: '' }, [
{ name: ' Acme ', address: ' 2 New St ', contactEmail: '' }, '1 Old St',
['1 Old St', '3 Extra Ave'] '3 Extra Ave',
); ]);
// contactEmail is ALWAYS sent — empty string clears the stored email // contactEmail is ALWAYS sent — empty string clears the stored email
// (code-review finding, Phase 4: omission made emails impossible to clear). // (code-review finding, Phase 4: omission made emails impossible to clear).
expect(request).toEqual({ name: 'Acme', addresses: ['2 New St', '3 Extra Ave'], contactEmail: '' }); expect(request).toEqual({
name: 'Acme',
addresses: ['2 New St', '3 Extra Ave'],
contactEmail: '',
});
}); });
}); });
@ -102,17 +104,21 @@ describe('pricingLibraryItemFormSchema', () => {
it('accepts empty unitPrice and rejects negatives/non-numbers', () => { it('accepts empty unitPrice and rejects negatives/non-numbers', () => {
expect(pricingLibraryItemFormSchema.safeParse(valid).success).toBe(true); expect(pricingLibraryItemFormSchema.safeParse(valid).success).toBe(true);
expect( expect(pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: '-4' }).success).toBe(
pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: '-4' }).success false,
).toBe(false); );
expect( expect(pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: 'abc' }).success).toBe(
pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: 'abc' }).success false,
).toBe(false); );
}); });
it('maps empty unitPrice to null and drops empty optional strings', () => { it('maps empty unitPrice to null and drops empty optional strings', () => {
const request = toPricingLibraryItemRequest(valid); const request = toPricingLibraryItemRequest(valid);
expect(request).toEqual({ title: 'Condenser coil cleaning', serviceCategory: 'HVAC', unitPrice: null }); expect(request).toEqual({
title: 'Condenser coil cleaning',
serviceCategory: 'HVAC',
unitPrice: null,
});
const priced = toPricingLibraryItemRequest({ ...valid, unitPrice: '12.50', unit: 'each' }); const priced = toPricingLibraryItemRequest({ ...valid, unitPrice: '12.50', unit: 'each' });
expect(priced.unitPrice).toBe(12.5); expect(priced.unitPrice).toBe(12.5);
expect(priced.unit).toBe('each'); expect(priced.unit).toBe('each');
@ -131,7 +137,7 @@ describe('shared numeric form fields', () => {
unitPrice: '12abc', unitPrice: '12abc',
keywords: '', keywords: '',
description: '', description: '',
}).success }).success,
).toBe(false); ).toBe(false);
}); });
}); });

View file

@ -1,5 +1,9 @@
// Admin domain — contract type re-exports + view-only types. // Admin domain — contract type re-exports + view-only types.
export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts'; export type {
DashboardStats,
UpdateProposalRequest,
AuditEntry,
} from '@proposal-system/api-contracts';
/** View-only shape of GET /proposals/{id}/similar (RAG similarity results — /** View-only shape of GET /proposals/{id}/similar (RAG similarity results —
* no contract DTO yet; previously declared inline in SimilarProposalsPanel). */ * no contract DTO yet; previously declared inline in SimilarProposalsPanel). */

View file

@ -17,17 +17,13 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
* form edits a single primary address string (the wire type takes * form edits a single primary address string (the wire type takes
* addresses: string[]); toCustomerRequest() maps to the request shape. * addresses: string[]); toCustomerRequest() maps to the request shape.
*/ */
export const customerFormSchema = createCustomerRequestSchema export const customerFormSchema = createCustomerRequestSchema.pick({ name: true }).extend({
.pick({ name: true }) name: z.string().trim().min(1, 'Name is required'),
.extend({ address: z.string().trim().min(1, 'Address is required'),
name: z.string().trim().min(1, 'Name is required'), contactEmail: z.string().refine((v) => v.trim() === '' || EMAIL_RE.test(v.trim()), {
address: z.string().trim().min(1, 'Address is required'), message: 'Enter a valid email address',
contactEmail: z }),
.string() });
.refine((v) => v.trim() === '' || EMAIL_RE.test(v.trim()), {
message: 'Enter a valid email address',
}),
});
export type CustomerFormValues = z.infer<typeof customerFormSchema>; export type CustomerFormValues = z.infer<typeof customerFormSchema>;
@ -40,7 +36,7 @@ export type CustomerFormValues = z.infer<typeof customerFormSchema>;
*/ */
export function toCustomerRequest( export function toCustomerRequest(
values: CustomerFormValues, values: CustomerFormValues,
existingAddresses: string[] = [] existingAddresses: string[] = [],
): CreateCustomerRequest & UpdateCustomerRequest { ): CreateCustomerRequest & UpdateCustomerRequest {
return { return {
name: values.name.trim(), name: values.name.trim(),

View file

@ -25,7 +25,10 @@ export const pricingLibraryApi = {
}, },
/** PUT /api/pricing-library/{id} — update an existing pricing library item. */ /** PUT /api/pricing-library/{id} — update an existing pricing library item. */
update: async (id: string, data: UpdatePricingLibraryItemRequest): Promise<PricingLibraryItem> => { update: async (
id: string,
data: UpdatePricingLibraryItemRequest,
): Promise<PricingLibraryItem> => {
const res = await apiClient.put(`/pricing-library/${id}`, data); const res = await apiClient.put(`/pricing-library/${id}`, data);
return res.data; return res.data;
}, },

View file

@ -36,7 +36,7 @@ export type PricingLibraryItemFormValues = z.infer<typeof pricingLibraryItemForm
* empty unitPrice sent as null). * empty unitPrice sent as null).
*/ */
export function toPricingLibraryItemRequest( export function toPricingLibraryItemRequest(
values: PricingLibraryItemFormValues values: PricingLibraryItemFormValues,
): CreatePricingLibraryItemRequest & UpdatePricingLibraryItemRequest { ): CreatePricingLibraryItemRequest & UpdatePricingLibraryItemRequest {
return { return {
title: values.title.trim(), title: values.title.trim(),

View file

@ -39,7 +39,11 @@ export const proposalsApi = {
return res.data; return res.data;
}, },
uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise<PresignedUpload> => { uploadAttachment: async (
proposalId: string,
fileName: string,
vendorName?: string,
): Promise<PresignedUpload> => {
const params = new URLSearchParams({ fileName }); const params = new URLSearchParams({ fileName });
if (vendorName) params.append('vendorName', vendorName); if (vendorName) params.append('vendorName', vendorName);
const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`); const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`);

View file

@ -63,21 +63,24 @@ export interface CreateProposalVariables {
export function useCreateProposal() { export function useCreateProposal() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ request, vendorFile }: CreateProposalVariables): Promise<ProposalDetail> => { mutationFn: async ({
request,
vendorFile,
}: CreateProposalVariables): Promise<ProposalDetail> => {
const proposal = await proposalsApi.create(request); const proposal = await proposalsApi.create(request);
if (vendorFile) { if (vendorFile) {
try { try {
const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment( const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment(
proposal.id, proposal.id,
vendorFile.name vendorFile.name,
); );
// Throws on S3 rejection (non-2xx) so a failed upload is never confirmed. // Throws on S3 rejection (non-2xx) so a failed upload is never confirmed.
await proposalsApi.uploadToPresignedUrl(uploadUrl, vendorFile); await proposalsApi.uploadToPresignedUrl(uploadUrl, vendorFile);
await proposalsApi.confirmUpload(proposal.id, vendorProposalId); await proposalsApi.confirmUpload(proposal.id, vendorProposalId);
} catch { } catch {
toast.warning( toast.warning(
`Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.` `Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.`,
); );
} }
} }

View file

@ -5,8 +5,16 @@ import { useAuthContext } from '../providers/authContext';
// Thin wrapper over the auth context (SHOC shape) that layers on the // Thin wrapper over the auth context (SHOC shape) that layers on the
// router/toast side effects components expect from logout. // router/toast side effects components expect from logout.
export function useAuth() { export function useAuth() {
const { user, isAuthenticated, isLoading, error, login, logout: clearSession, setError, setLoading } = const {
useAuthContext(); user,
isAuthenticated,
isLoading,
error,
login,
logout: clearSession,
setError,
setLoading,
} = useAuthContext();
const navigate = useNavigate(); const navigate = useNavigate();
const logout = () => { const logout = () => {

View file

@ -5,6 +5,8 @@ const BASE_TITLE = 'Proposal System';
export function useDocumentTitle(title?: string) { export function useDocumentTitle(title?: string) {
useEffect(() => { useEffect(() => {
document.title = title ? `${title} | ${BASE_TITLE}` : BASE_TITLE; document.title = title ? `${title} | ${BASE_TITLE}` : BASE_TITLE;
return () => { document.title = BASE_TITLE; }; return () => {
document.title = BASE_TITLE;
};
}, [title]); }, [title]);
} }

View file

@ -3,7 +3,9 @@
box-sizing: border-box; box-sizing: border-box;
} }
html, body, #root { html,
body,
#root {
height: 100%; height: 100%;
} }

View file

@ -57,10 +57,18 @@ vi.mock('axios', () => {
const sessionStorageData: Record<string, string> = {}; const sessionStorageData: Record<string, string> = {};
vi.stubGlobal('sessionStorage', { vi.stubGlobal('sessionStorage', {
getItem: vi.fn((key: string) => sessionStorageData[key] ?? null), getItem: vi.fn((key: string) => sessionStorageData[key] ?? null),
setItem: vi.fn((key: string, value: string) => { sessionStorageData[key] = value; }), setItem: vi.fn((key: string, value: string) => {
removeItem: vi.fn((key: string) => { delete sessionStorageData[key]; }), sessionStorageData[key] = value;
clear: vi.fn(() => { Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]); }), }),
get length() { return Object.keys(sessionStorageData).length; }, removeItem: vi.fn((key: string) => {
delete sessionStorageData[key];
}),
clear: vi.fn(() => {
Object.keys(sessionStorageData).forEach((k) => delete sessionStorageData[k]);
}),
get length() {
return Object.keys(sessionStorageData).length;
},
key: vi.fn((index: number) => Object.keys(sessionStorageData)[index] ?? null), key: vi.fn((index: number) => Object.keys(sessionStorageData)[index] ?? null),
}); });
@ -80,7 +88,7 @@ describe('API client interceptors', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]); Object.keys(sessionStorageData).forEach((k) => delete sessionStorageData[k]);
mockLocation.href = ''; mockLocation.href = '';
}); });
@ -250,7 +258,9 @@ describe('API client interceptors', () => {
const promise = interceptors.responseRejected(error); const promise = interceptors.responseRejected(error);
await expect(promise).rejects.toThrow('No response from server. Please check your connection.'); await expect(promise).rejects.toThrow(
'No response from server. Please check your connection.',
);
}); });
it('passes through non-axios errors unchanged', async () => { it('passes through non-axios errors unchanged', async () => {

View file

@ -25,7 +25,7 @@ apiClient.interceptors.request.use(
} }
return config; return config;
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error),
); );
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
@ -60,7 +60,7 @@ apiClient.interceptors.response.use(
} }
return Promise.reject(error); return Promise.reject(error);
} },
); );
export default apiClient; export default apiClient;

View file

@ -14,10 +14,12 @@ import { STORAGE_KEY_TOKEN } from '../../../constants';
function createToken(expOffsetSeconds: number): string { function createToken(expOffsetSeconds: number): string {
const header = btoa(JSON.stringify({ alg: 'HS256' })); const header = btoa(JSON.stringify({ alg: 'HS256' }));
const payload = btoa(JSON.stringify({ const payload = btoa(
sub: 'test-user', JSON.stringify({
exp: Math.floor(Date.now() / 1000) + expOffsetSeconds, sub: 'test-user',
})); exp: Math.floor(Date.now() / 1000) + expOffsetSeconds,
}),
);
return `${header}.${payload}.fake-signature`; return `${header}.${payload}.fake-signature`;
} }
@ -69,7 +71,9 @@ describe('authStorage', () => {
it('isTokenValid accepts a base64url payload (JWT segments are base64url, not base64)', () => { it('isTokenValid accepts a base64url payload (JWT segments are base64url, not base64)', () => {
// '>>>' encodes to 'Pj4-' in base64url — the '-' made the old atob() call throw. // '>>>' encodes to 'Pj4-' in base64url — the '-' made the old atob() call throw.
const payload = btoa(JSON.stringify({ sub: '>>>>>>', exp: Math.floor(Date.now() / 1000) + 3600 })) const payload = btoa(
JSON.stringify({ sub: '>>>>>>', exp: Math.floor(Date.now() / 1000) + 3600 }),
)
.replace(/\+/g, '-') .replace(/\+/g, '-')
.replace(/\//g, '_') .replace(/\//g, '_')
.replace(/=+$/, ''); .replace(/=+$/, '');

View file

@ -17,7 +17,11 @@ export function formatDate(iso: string): string {
} }
export function formatDateFull(iso: string): string { export function formatDateFull(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} }
export function formatDateTime(iso: string | null): string { export function formatDateTime(iso: string | null): string {
@ -58,7 +62,9 @@ export interface GroupedProposal<T extends { proposalNumber: string }> {
baseNumber: string; baseNumber: string;
} }
export function groupRevisions<T extends { proposalNumber: string }>(rows: T[]): GroupedProposal<T>[] { export function groupRevisions<T extends { proposalNumber: string }>(
rows: T[],
): GroupedProposal<T>[] {
const groups = new Map<string, T[]>(); const groups = new Map<string, T[]>();
for (const row of rows) { for (const row of rows) {
const base = row.proposalNumber.replace(/-R\d+$/, ''); const base = row.proposalNumber.replace(/-R\d+$/, '');

View file

@ -37,9 +37,18 @@ const radius = parseInt(v('--radius', `${CSS_VAR_FALLBACKS.radius}px`), 10);
const headerEnd = v('--color-header-bg-end', CSS_VAR_FALLBACKS.headerEnd); const headerEnd = v('--color-header-bg-end', CSS_VAR_FALLBACKS.headerEnd);
const headerText = v('--color-header-text', CSS_VAR_FALLBACKS.headerText); const headerText = v('--color-header-text', CSS_VAR_FALLBACKS.headerText);
const shadowSm = v('--shadow-sm', '0 1px 3px rgba(22, 30, 56, 0.06), 0 1px 2px rgba(22, 30, 56, 0.04)'); const shadowSm = v(
const shadowMd = v('--shadow-md', '0 4px 6px -1px rgba(22, 30, 56, 0.08), 0 2px 4px -2px rgba(22, 30, 56, 0.06)'); '--shadow-sm',
const shadowLg = v('--shadow-lg', '0 10px 15px -3px rgba(22, 30, 56, 0.1), 0 4px 6px -4px rgba(22, 30, 56, 0.08)'); '0 1px 3px rgba(22, 30, 56, 0.06), 0 1px 2px rgba(22, 30, 56, 0.04)',
);
const shadowMd = v(
'--shadow-md',
'0 4px 6px -1px rgba(22, 30, 56, 0.08), 0 2px 4px -2px rgba(22, 30, 56, 0.06)',
);
const shadowLg = v(
'--shadow-lg',
'0 10px 15px -3px rgba(22, 30, 56, 0.1), 0 4px 6px -4px rgba(22, 30, 56, 0.08)',
);
const fontSans = '"DM Sans", ui-sans-serif, system-ui, sans-serif'; const fontSans = '"DM Sans", ui-sans-serif, system-ui, sans-serif';
const fontDisplay = '"Montserrat", ui-sans-serif, system-ui, sans-serif'; const fontDisplay = '"Montserrat", ui-sans-serif, system-ui, sans-serif';
@ -149,7 +158,8 @@ export const muiTheme = createTheme({
textTransform: 'none' as const, textTransform: 'none' as const,
fontWeight: 600, fontWeight: 600,
fontSize: '0.875rem', fontSize: '0.875rem',
transition: 'background-color var(--duration-fast) var(--ease-default), box-shadow var(--duration-fast) var(--ease-default)', transition:
'background-color var(--duration-fast) var(--ease-default), box-shadow var(--duration-fast) var(--ease-default)',
'&:focus-visible': { '&:focus-visible': {
outline: `2px solid ${ring}`, outline: `2px solid ${ring}`,
outlineOffset: 2, outlineOffset: 2,

View file

@ -26,5 +26,5 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
</AuthProvider> </AuthProvider>
</React.StrictMode> </React.StrictMode>,
); );

View file

@ -120,10 +120,7 @@ export default function CustomerManagementPage() {
const payload = toCustomerRequest(values, editingCustomer?.addresses ?? []); const payload = toCustomerRequest(values, editingCustomer?.addresses ?? []);
if (isEditing) { if (isEditing) {
updateMutation.mutate( updateMutation.mutate({ id: editingCustomer.id, data: payload }, { onSuccess: closeDialog });
{ id: editingCustomer.id, data: payload },
{ onSuccess: closeDialog }
);
} else { } else {
createMutation.mutate(payload, { onSuccess: closeDialog }); createMutation.mutate(payload, { onSuccess: closeDialog });
} }
@ -135,11 +132,7 @@ export default function CustomerManagementPage() {
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h5">Customer Management</Typography> <Typography variant="h5">Customer Management</Typography>
<Button <Button variant="contained" startIcon={<AddIcon />} onClick={openCreateDialog}>
variant="contained"
startIcon={<AddIcon />}
onClick={openCreateDialog}
>
New Customer New Customer
</Button> </Button>
</Box> </Box>
@ -222,10 +215,7 @@ export default function CustomerManagementPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<Tooltip title="Edit customer" arrow> <Tooltip title="Edit customer" arrow>
<IconButton <IconButton size="small" onClick={() => openEditDialog(customer)}>
size="small"
onClick={() => openEditDialog(customer)}
>
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@ -289,7 +279,9 @@ export default function CustomerManagementPage() {
type="email" type="email"
{...register('contactEmail')} {...register('contactEmail')}
error={!!errors.contactEmail} error={!!errors.contactEmail}
helperText={errors.contactEmail?.message || 'Optional. Used for proposal PDF delivery.'} helperText={
errors.contactEmail?.message || 'Optional. Used for proposal PDF delivery.'
}
size="small" size="small"
fullWidth fullWidth
/> />
@ -299,12 +291,14 @@ export default function CustomerManagementPage() {
<Button onClick={closeDialog} disabled={isSaving}> <Button onClick={closeDialog} disabled={isSaving}>
Cancel Cancel
</Button> </Button>
<Button <Button type="submit" variant="contained" disabled={isSaving}>
type="submit" {isSaving
variant="contained" ? isEditing
disabled={isSaving} ? 'Saving...'
> : 'Creating...'
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Customer'} : isEditing
? 'Save Changes'
: 'Create Customer'}
</Button> </Button>
</DialogActions> </DialogActions>
</form> </form>

View file

@ -39,27 +39,82 @@ import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
import { useProposals } from '../../../domain/proposals/use-cases'; import { useProposals } from '../../../domain/proposals/use-cases';
import type { ProposalListItem } from '../../../domain/proposals/types'; import type { ProposalListItem } from '../../../domain/proposals/types';
import { useAdminDashboard } from '../../../domain/admin/use-cases'; import { useAdminDashboard } from '../../../domain/admin/use-cases';
import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_CHIP_STYLES, PRIORITY_ROW_SX } from '../../../constants'; import {
import { formatBidAmount, formatDate, formatDateFull, formatAge, getAgeColor, groupRevisions } from '../../../lib/format'; DEBOUNCE_SEARCH,
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
SERVICE_CATEGORIES,
PRIORITIES,
PROPOSAL_STATUSES,
STATUS_CHIP_STYLES,
STATUS_LABELS,
PRIORITY_LABELS,
PRIORITY_CHIP_STYLES,
PRIORITY_ROW_SX,
} from '../../../constants';
import {
formatBidAmount,
formatDate,
formatDateFull,
formatAge,
getAgeColor,
groupRevisions,
} from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
const COL_COUNT = 11; const COL_COUNT = 11;
const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const; const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
function StatCard({ icon, label, value, color, subtitle }: { icon: React.ReactNode; label: string; value: string; color: string; subtitle?: string }) { function StatCard({
icon,
label,
value,
color,
subtitle,
}: {
icon: React.ReactNode;
label: string;
value: string;
color: string;
subtitle?: string;
}) {
return ( return (
<Card sx={{ borderRadius: '8px', transition: 'border-color 120ms ease', '&:hover': { borderColor: '#9CA3AF' } }}> <Card
sx={{
borderRadius: '8px',
transition: 'border-color 120ms ease',
'&:hover': { borderColor: '#9CA3AF' },
}}
>
<CardContent sx={{ textAlign: 'center', py: 2.5 }}> <CardContent sx={{ textAlign: 'center', py: 2.5 }}>
<Box sx={{ color, display: 'flex', justifyContent: 'center', mb: 1 }}>{icon}</Box> <Box sx={{ color, display: 'flex', justifyContent: 'center', mb: 1 }}>{icon}</Box>
<Typography sx={{ fontSize: '12px', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, mb: 1 }}> <Typography
sx={{
fontSize: '12px',
color: '#64748B',
textTransform: 'uppercase',
letterSpacing: '0.06em',
fontWeight: 600,
mb: 1,
}}
>
{label} {label}
</Typography> </Typography>
<Typography sx={{ fontSize: '30px', fontWeight: 700, color: 'var(--foreground)', letterSpacing: '-0.02em' }}> <Typography
sx={{
fontSize: '30px',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.02em',
}}
>
{value} {value}
</Typography> </Typography>
{subtitle && ( {subtitle && (
<Typography sx={{ fontSize: '11px', color: '#94A3B8', fontWeight: 500, marginTop: '4px' }}> <Typography
sx={{ fontSize: '11px', color: '#94A3B8', fontWeight: 500, marginTop: '4px' }}
>
{subtitle} {subtitle}
</Typography> </Typography>
)} )}
@ -74,7 +129,12 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
const showTabs = !defaultStatus; const showTabs = !defaultStatus;
// Fix: WEB-M8 — capture error state for admin stats to show error instead of misleading zeros // Fix: WEB-M8 — capture error state for admin stats to show error instead of misleading zeros
const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useAdminDashboard(); const {
data: stats,
isLoading: statsLoading,
isError: statsError,
refetch: refetchStats,
} = useAdminDashboard();
const [statusFilter, setStatusFilter] = useState(defaultStatus ?? ''); const [statusFilter, setStatusFilter] = useState(defaultStatus ?? '');
const [categoryFilter, setCategoryFilter] = useState(''); const [categoryFilter, setCategoryFilter] = useState('');
@ -120,7 +180,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
}); });
}; };
const tabIndex = TAB_STATUSES.indexOf(statusFilter as typeof TAB_STATUSES[number]); const tabIndex = TAB_STATUSES.indexOf(statusFilter as (typeof TAB_STATUSES)[number]);
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
setStatusFilter(TAB_STATUSES[newValue] ?? ''); setStatusFilter(TAB_STATUSES[newValue] ?? '');
@ -137,7 +197,13 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
setPage(1); setPage(1);
}; };
const renderRow = (row: ProposalListItem, indent: boolean, showExpandIcon: boolean, isExpanded: boolean, baseNumber: string) => ( const renderRow = (
row: ProposalListItem,
indent: boolean,
showExpandIcon: boolean,
isExpanded: boolean,
baseNumber: string,
) => (
<TableRow <TableRow
key={row.id} key={row.id}
hover hover
@ -157,7 +223,11 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
toggleExpand(baseNumber); toggleExpand(baseNumber);
}} }}
> >
{isExpanded ? <KeyboardArrowDownIcon fontSize="small" /> : <KeyboardArrowRightIcon fontSize="small" />} {isExpanded ? (
<KeyboardArrowDownIcon fontSize="small" />
) : (
<KeyboardArrowRightIcon fontSize="small" />
)}
</IconButton> </IconButton>
)} )}
</TableCell> </TableCell>
@ -170,11 +240,21 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
label={PRIORITY_LABELS[row.priority] || row.priority} label={PRIORITY_LABELS[row.priority] || row.priority}
size="small" size="small"
sx={{ ...PRIORITY_CHIP_STYLES[row.priority], '& .MuiChip-icon': { color: 'inherit' } }} sx={{ ...PRIORITY_CHIP_STYLES[row.priority], '& .MuiChip-icon': { color: 'inherit' } }}
icon={row.priority === 'Emergency' ? <ReportIcon /> : row.priority === 'Urgent' ? <WarningAmberIcon /> : undefined} icon={
row.priority === 'Emergency' ? (
<ReportIcon />
) : row.priority === 'Urgent' ? (
<WarningAmberIcon />
) : undefined
}
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
<Chip label={STATUS_LABELS[row.status] || row.status} size="small" sx={STATUS_CHIP_STYLES[row.status]} /> <Chip
label={STATUS_LABELS[row.status] || row.status}
size="small"
sx={STATUS_CHIP_STYLES[row.status]}
/>
</TableCell> </TableCell>
<TableCell align="right">{formatBidAmount(row.totalBidAmount)}</TableCell> <TableCell align="right">{formatBidAmount(row.totalBidAmount)}</TableCell>
<TableCell> <TableCell>
@ -183,7 +263,12 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
</Tooltip> </Tooltip>
</TableCell> </TableCell>
<TableCell>{row.submittedByName || '-'}</TableCell> <TableCell>{row.submittedByName || '-'}</TableCell>
<TableCell sx={{ color: getAgeColor(row.submittedAt), fontWeight: getAgeColor(row.submittedAt) ? 600 : undefined }}> <TableCell
sx={{
color: getAgeColor(row.submittedAt),
fontWeight: getAgeColor(row.submittedAt) ? 600 : undefined,
}}
>
{formatAge(row.submittedAt)} {formatAge(row.submittedAt)}
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -191,7 +276,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
return ( return (
<Box> <Box>
<Typography variant="h5" sx={{ mb: 3 }}>{defaultStatus ? 'Admin Queue' : 'All Proposals'}</Typography> <Typography variant="h5" sx={{ mb: 3 }}>
{defaultStatus ? 'Admin Queue' : 'All Proposals'}
</Typography>
{/* Fix: WEB-M8 — show error state instead of misleading zeros when stats fetch fails */} {/* Fix: WEB-M8 — show error state instead of misleading zeros when stats fetch fails */}
<Grid container spacing={2} sx={{ mb: 3 }}> <Grid container spacing={2} sx={{ mb: 3 }}>
@ -312,7 +399,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
> >
<MenuItem value="">All</MenuItem> <MenuItem value="">All</MenuItem>
{SERVICE_CATEGORIES.map((c) => ( {SERVICE_CATEGORIES.map((c) => (
<MenuItem key={c} value={c}>{c}</MenuItem> <MenuItem key={c} value={c}>
{c}
</MenuItem>
))} ))}
</TextField> </TextField>
<TextField <TextField
@ -328,7 +417,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
> >
<MenuItem value="">All</MenuItem> <MenuItem value="">All</MenuItem>
{PRIORITIES.map((p) => ( {PRIORITIES.map((p) => (
<MenuItem key={p} value={p}>{PRIORITY_LABELS[p] || p}</MenuItem> <MenuItem key={p} value={p}>
{PRIORITY_LABELS[p] || p}
</MenuItem>
))} ))}
</TextField> </TextField>
{!showTabs && ( {!showTabs && (
@ -345,7 +436,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
> >
<MenuItem value="">All</MenuItem> <MenuItem value="">All</MenuItem>
{PROPOSAL_STATUSES.map((s) => ( {PROPOSAL_STATUSES.map((s) => (
<MenuItem key={s} value={s}>{STATUS_LABELS[s] || s}</MenuItem> <MenuItem key={s} value={s}>
{STATUS_LABELS[s] || s}
</MenuItem>
))} ))}
</TextField> </TextField>
)} )}
@ -357,7 +450,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
</Box> </Box>
{err && ( {err && (
<Typography color="error" sx={{ mb: 2 }}>{err}</Typography> <Typography color="error" sx={{ mb: 2 }}>
{err}
</Typography>
)} )}
<TableContainer> <TableContainer>
@ -382,7 +477,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
? Array.from({ length: 5 }).map((_, i) => ( ? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}> <TableRow key={i}>
{Array.from({ length: COL_COUNT }).map((_, j) => ( {Array.from({ length: COL_COUNT }).map((_, j) => (
<TableCell key={j}><Skeleton variant="text" /></TableCell> <TableCell key={j}>
<Skeleton variant="text" />
</TableCell>
))} ))}
</TableRow> </TableRow>
)) ))
@ -390,7 +487,13 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
const isExpanded = expanded.has(group.baseNumber); const isExpanded = expanded.has(group.baseNumber);
return ( return (
<Fragment key={group.baseNumber}> <Fragment key={group.baseNumber}>
{renderRow(group.latest, false, group.hasRevisions, isExpanded, group.baseNumber)} {renderRow(
group.latest,
false,
group.hasRevisions,
isExpanded,
group.baseNumber,
)}
{group.hasRevisions && ( {group.hasRevisions && (
<TableRow> <TableRow>
<TableCell colSpan={COL_COUNT} sx={{ p: 0, border: 0 }}> <TableCell colSpan={COL_COUNT} sx={{ p: 0, border: 0 }}>
@ -398,7 +501,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
<Table size="small"> <Table size="small">
<TableBody> <TableBody>
{group.revisions.map((rev) => {group.revisions.map((rev) =>
renderRow(rev, true, false, false, group.baseNumber) renderRow(rev, true, false, false, group.baseNumber),
)} )}
</TableBody> </TableBody>
</Table> </Table>

View file

@ -149,7 +149,7 @@ export default function PricingLibraryPage() {
if (editingItem) { if (editingItem) {
updateMutation.mutate( updateMutation.mutate(
{ id: editingItem.id, data: payload }, { id: editingItem.id, data: payload },
{ onSuccess: () => closeDialog() } { onSuccess: () => closeDialog() },
); );
} else { } else {
createMutation.mutate(payload, { onSuccess: () => closeDialog() }); createMutation.mutate(payload, { onSuccess: () => closeDialog() });
@ -168,11 +168,7 @@ export default function PricingLibraryPage() {
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h5">Pricing Library</Typography> <Typography variant="h5">Pricing Library</Typography>
<Button <Button variant="contained" startIcon={<AddIcon />} onClick={openCreateDialog}>
variant="contained"
startIcon={<AddIcon />}
onClick={openCreateDialog}
>
New Item New Item
</Button> </Button>
</Box> </Box>
@ -220,7 +216,11 @@ export default function PricingLibraryPage() {
</TableRow> </TableRow>
)) ))
: rows.map((item) => { : rows.map((item) => {
const defaultChip = { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' }; const defaultChip = {
bgcolor: '#F1F5F9',
color: '#475569',
border: '1px solid #CBD5E1',
};
const chipStyle = CATEGORY_CHIP_STYLES[item.serviceCategory] ?? defaultChip; const chipStyle = CATEGORY_CHIP_STYLES[item.serviceCategory] ?? defaultChip;
return ( return (
<TableRow key={item.id} hover> <TableRow key={item.id} hover>
@ -239,17 +239,16 @@ export default function PricingLibraryPage() {
/> />
</TableCell> </TableCell>
<TableCell>{item.unit ?? '—'}</TableCell> <TableCell>{item.unit ?? '—'}</TableCell>
<TableCell sx={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}> <TableCell
sx={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
>
{formatPrice(item.unitPrice)} {formatPrice(item.unitPrice)}
</TableCell> </TableCell>
<TableCell>{item.source}</TableCell> <TableCell>{item.source}</TableCell>
<TableCell> <TableCell>
<Box sx={{ display: 'flex', gap: 0.5 }}> <Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Edit item" arrow> <Tooltip title="Edit item" arrow>
<IconButton <IconButton size="small" onClick={() => openEditDialog(item)}>
size="small"
onClick={() => openEditDialog(item)}
>
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@ -359,7 +358,9 @@ export default function PricingLibraryPage() {
label="Unit Price" label="Unit Price"
type="number" type="number"
error={!!errors.unitPrice} error={!!errors.unitPrice}
helperText={errors.unitPrice?.message || 'Optional. Leave blank if price varies.'} helperText={
errors.unitPrice?.message || 'Optional. Leave blank if price varies.'
}
size="small" size="small"
fullWidth fullWidth
slotProps={{ slotProps={{
@ -403,12 +404,14 @@ export default function PricingLibraryPage() {
<Button onClick={closeDialog} disabled={isSaving}> <Button onClick={closeDialog} disabled={isSaving}>
Cancel Cancel
</Button> </Button>
<Button <Button type="submit" variant="contained" disabled={isSaving}>
type="submit" {isSaving
variant="contained" ? isEditing
disabled={isSaving} ? 'Saving...'
> : 'Creating...'
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'} : isEditing
? 'Save Changes'
: 'Create Item'}
</Button> </Button>
</DialogActions> </DialogActions>
</form> </form>
@ -419,7 +422,8 @@ export default function PricingLibraryPage() {
<DialogTitle>Delete Pricing Item</DialogTitle> <DialogTitle>Delete Pricing Item</DialogTitle>
<DialogContent> <DialogContent>
<Typography> <Typography>
Are you sure you want to delete <strong>{deleteTarget?.title}</strong>? This action cannot be undone. Are you sure you want to delete <strong>{deleteTarget?.title}</strong>? This action
cannot be undone.
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>

View file

@ -105,7 +105,7 @@ export default function AdminWorkspace() {
pricingMode: li.pricingMode, pricingMode: li.pricingMode,
sortOrder: li.sortOrder, sortOrder: li.sortOrder,
source: li.source, source: li.source,
})) })),
); );
} }
}, [fetchedLineItems]); }, [fetchedLineItems]);
@ -128,14 +128,17 @@ export default function AdminWorkspace() {
const [navBlocked, setNavBlocked] = useState(false); const [navBlocked, setNavBlocked] = useState(false);
const pendingNavRef = useRef<string | null>(null); const pendingNavRef = useRef<string | null>(null);
const guardedNavigate = useCallback((to: string) => { const guardedNavigate = useCallback(
if (dirty) { (to: string) => {
pendingNavRef.current = to; if (dirty) {
setNavBlocked(true); pendingNavRef.current = to;
} else { setNavBlocked(true);
navigate(to); } else {
} navigate(to);
}, [dirty, navigate]); }
},
[dirty, navigate],
);
// Domain mutations: cache invalidation + success/error toasts live in the // Domain mutations: cache invalidation + success/error toasts live in the
// hooks; page-specific side effects (dialog close, dirty reset, window.open) // hooks; page-specific side effects (dialog close, dirty reset, window.open)
@ -152,7 +155,7 @@ export default function AdminWorkspace() {
const handleSave = () => { const handleSave = () => {
saveMutation.mutate( saveMutation.mutate(
{ refinedScope, lineItems: toUpdateEntries(lineItems) }, { refinedScope, lineItems: toUpdateEntries(lineItems) },
{ onSuccess: () => setDirty(false) } { onSuccess: () => setDirty(false) },
); );
}; };
@ -227,9 +230,14 @@ export default function AdminWorkspace() {
</Button> </Button>
} }
> >
Failed to load proposal{proposalError instanceof Error ? `: ${proposalError.message}` : '.'} Failed to load proposal
{proposalError instanceof Error ? `: ${proposalError.message}` : '.'}
</Alert> </Alert>
<Button startIcon={<ArrowBackIcon />} onClick={() => guardedNavigate('/admin')} sx={{ mt: 2 }}> <Button
startIcon={<ArrowBackIcon />}
onClick={() => guardedNavigate('/admin')}
sx={{ mt: 2 }}
>
Back to Dashboard Back to Dashboard
</Button> </Button>
</Box> </Box>
@ -240,7 +248,11 @@ export default function AdminWorkspace() {
return ( return (
<Box> <Box>
<Typography color="error">Proposal not found</Typography> <Typography color="error">Proposal not found</Typography>
<Button startIcon={<ArrowBackIcon />} onClick={() => guardedNavigate('/admin')} sx={{ mt: 2 }}> <Button
startIcon={<ArrowBackIcon />}
onClick={() => guardedNavigate('/admin')}
sx={{ mt: 2 }}
>
Back to Dashboard Back to Dashboard
</Button> </Button>
</Box> </Box>
@ -257,16 +269,19 @@ export default function AdminWorkspace() {
const bidTotal = lineItems.reduce((sum, li) => sum + li.totalPrice, 0); const bidTotal = lineItems.reduce((sum, li) => sum + li.totalPrice, 0);
// Tooltip messages for disabled actions // Tooltip messages for disabled actions
const approveDisabledReason = const approveDisabledReason = !isEditable
!isEditable ? `Cannot approve: proposal is ${proposal.status}`
? `Cannot approve: proposal is ${proposal.status}` : lineItems.length === 0
: lineItems.length === 0 ? 'Add at least one line item before approving'
? 'Add at least one line item before approving' : bidTotal <= 0
: bidTotal <= 0 ? 'Total bid must be greater than zero'
? 'Total bid must be greater than zero' : '';
: ''; const sendDisabledReason = !canSend
const sendDisabledReason = !canSend ? `Cannot send: proposal must be Approved (currently ${proposal.status})` : ''; ? `Cannot send: proposal must be Approved (currently ${proposal.status})`
const reviseDisabledReason = !canRevise ? `Cannot revise: proposal must be Sent (currently ${proposal.status})` : ''; : '';
const reviseDisabledReason = !canRevise
? `Cannot revise: proposal must be Sent (currently ${proposal.status})`
: '';
return ( return (
<Box> <Box>
@ -282,9 +297,7 @@ export default function AdminWorkspace() {
Admin Queue Admin Queue
</Link> </Link>
<NavigateNextIcon sx={{ fontSize: 16, color: 'text.secondary' }} /> <NavigateNextIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography sx={{ fontSize: 26, fontWeight: 800 }}> <Typography sx={{ fontSize: 26, fontWeight: 800 }}>{proposal.proposalNumber}</Typography>
{proposal.proposalNumber}
</Typography>
</Box> </Box>
<Chip label={`Rev ${proposal.currentRevision}`} size="small" variant="outlined" /> <Chip label={`Rev ${proposal.currentRevision}`} size="small" variant="outlined" />
<Chip label={proposal.status} sx={STATUS_CHIP_STYLES[proposal.status]} /> <Chip label={proposal.status} sx={STATUS_CHIP_STYLES[proposal.status]} />
@ -292,25 +305,41 @@ export default function AdminWorkspace() {
{/* Proposal Info Bar */} {/* Proposal Info Bar */}
<Card sx={{ mb: 2 }}> <Card sx={{ mb: 2 }}>
<CardContent sx={{ py: 1.5, display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}> <CardContent
sx={{ py: 1.5, display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}
>
<Box> <Box>
<Typography variant="caption" color="text.secondary">Customer</Typography> <Typography variant="caption" color="text.secondary">
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.customerName}</Typography> Customer
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{proposal.customerName}
</Typography>
</Box> </Box>
<Box> <Box>
<Typography variant="caption" color="text.secondary">WO #</Typography> <Typography variant="caption" color="text.secondary">
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.workOrderNumber}</Typography> WO #
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{proposal.workOrderNumber}
</Typography>
</Box> </Box>
<Box> <Box>
<Typography variant="caption" color="text.secondary">Category</Typography> <Typography variant="caption" color="text.secondary">
Category
</Typography>
<Typography variant="body2">{proposal.serviceCategory}</Typography> <Typography variant="body2">{proposal.serviceCategory}</Typography>
</Box> </Box>
<Box> <Box>
<Typography variant="caption" color="text.secondary">Priority</Typography> <Typography variant="caption" color="text.secondary">
Priority
</Typography>
<Chip label={proposal.priority} size="small" sx={{ ml: 0.5 }} /> <Chip label={proposal.priority} size="small" sx={{ ml: 0.5 }} />
</Box> </Box>
<Box> <Box>
<Typography variant="caption" color="text.secondary">Address</Typography> <Typography variant="caption" color="text.secondary">
Address
</Typography>
<Typography variant="body2">{proposal.customerAddress}</Typography> <Typography variant="body2">{proposal.customerAddress}</Typography>
</Box> </Box>
</CardContent> </CardContent>
@ -320,28 +349,51 @@ export default function AdminWorkspace() {
<Grid container spacing={2}> <Grid container spacing={2}>
{/* Left Panel: Reference */} {/* Left Panel: Reference */}
<Grid size={{ xs: 12, md: 3 }}> <Grid size={{ xs: 12, md: 3 }}>
<Card sx={{ position: 'sticky', top: 88, alignSelf: 'flex-start', maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}> <Card
sx={{
position: 'sticky',
top: 88,
alignSelf: 'flex-start',
maxHeight: 'calc(100vh - 120px)',
overflowY: 'auto',
}}
>
<CardContent> <CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Submission Details</Typography> <Typography variant="subtitle2" sx={{ mb: 1 }}>
Submission Details
</Typography>
<Typography variant="caption" color="text.secondary">Submitted By</Typography> <Typography variant="caption" color="text.secondary">
<Typography variant="body2" sx={{ mb: 1 }}>{proposal.submittedByName || '-'}</Typography> Submitted By
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{proposal.submittedByName || '-'}
</Typography>
<Typography variant="caption" color="text.secondary">Submitted</Typography> <Typography variant="caption" color="text.secondary">
Submitted
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}> <Typography variant="body2" sx={{ mb: 1 }}>
{new Date(proposal.submittedAt).toLocaleString()} {new Date(proposal.submittedAt).toLocaleString()}
</Typography> </Typography>
<Divider sx={{ my: 1.5 }} /> <Divider sx={{ my: 1.5 }} />
<Typography variant="caption" color="text.secondary">Original Scope</Typography> <Typography variant="caption" color="text.secondary">
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', mb: 1, fontSize: '0.8rem' }}> Original Scope
</Typography>
<Typography
variant="body2"
sx={{ whiteSpace: 'pre-wrap', mb: 1, fontSize: '0.8rem' }}
>
{proposal.scopeOfWork} {proposal.scopeOfWork}
</Typography> </Typography>
{proposal.notes && ( {proposal.notes && (
<> <>
<Typography variant="caption" color="text.secondary">Notes</Typography> <Typography variant="caption" color="text.secondary">
Notes
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', fontSize: '0.8rem' }}> <Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', fontSize: '0.8rem' }}>
{proposal.notes} {proposal.notes}
</Typography> </Typography>
@ -358,14 +410,19 @@ export default function AdminWorkspace() {
<Grid size={{ xs: 12, md: hasSimilar ? 6 : 9 }}> <Grid size={{ xs: 12, md: hasSimilar ? 6 : 9 }}>
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Refined Scope</Typography> <Typography variant="subtitle2" sx={{ mb: 1 }}>
Refined Scope
</Typography>
<TextField <TextField
fullWidth fullWidth
multiline multiline
minRows={3} minRows={3}
maxRows={6} maxRows={6}
value={refinedScope} value={refinedScope}
onChange={(e) => { setRefinedScope(e.target.value); setDirty(true); }} onChange={(e) => {
setRefinedScope(e.target.value);
setDirty(true);
}}
disabled={!isEditable} disabled={!isEditable}
size="small" size="small"
sx={{ mb: 2 }} sx={{ mb: 2 }}
@ -374,12 +431,29 @@ export default function AdminWorkspace() {
{/* Fix: WEB-M9 — show loading skeleton while line items are being fetched */} {/* Fix: WEB-M9 — show loading skeleton while line items are being fetched */}
{lineItemsLoading ? ( {lineItemsLoading ? (
<Box> <Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}> <Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1,
}}
>
<Skeleton variant="text" width={120} /> <Skeleton variant="text" width={120} />
<Skeleton variant="rectangular" width={120} height={32} sx={{ borderRadius: 1 }} /> <Skeleton
variant="rectangular"
width={120}
height={32}
sx={{ borderRadius: 1 }}
/>
</Box> </Box>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} variant="rectangular" height={48} sx={{ mb: 0.5, borderRadius: 0.5 }} /> <Skeleton
key={i}
variant="rectangular"
height={48}
sx={{ mb: 0.5, borderRadius: 0.5 }}
/>
))} ))}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Skeleton variant="text" width={180} /> <Skeleton variant="text" width={180} />
@ -404,7 +478,8 @@ export default function AdminWorkspace() {
<SimilarProposalsPanel <SimilarProposalsPanel
proposalId={id!} proposalId={id!}
onPullLineItem={(item) => { onPullLineItem={(item) => {
const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0; const maxSort =
lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0;
handleLineItemsChange([ handleLineItemsChange([
...lineItems, ...lineItems,
{ ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' }, { ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' },
@ -419,10 +494,26 @@ export default function AdminWorkspace() {
</Grid> </Grid>
{/* Action Bar */} {/* Action Bar */}
<Card sx={{ mt: 2, position: 'sticky', bottom: 0, zIndex: 10, bgcolor: 'rgba(255,255,255,0.96)', backdropFilter: 'blur(8px)', borderTop: '1px solid #E5E7EB', boxShadow: '0 -4px 12px rgba(17,24,39,0.06)', border: 'none' }}> <Card
<CardContent sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}> sx={{
mt: 2,
position: 'sticky',
bottom: 0,
zIndex: 10,
bgcolor: 'rgba(255,255,255,0.96)',
backdropFilter: 'blur(8px)',
borderTop: '1px solid #E5E7EB',
boxShadow: '0 -4px 12px rgba(17,24,39,0.06)',
border: 'none',
}}
>
<CardContent
sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}
>
{dirty && ( {dirty && (
<Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>Unsaved changes</Alert> <Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>
Unsaved changes
</Alert>
)} )}
{!dirty && <Box sx={{ flexGrow: 1 }} />} {!dirty && <Box sx={{ flexGrow: 1 }} />}
@ -437,10 +528,7 @@ export default function AdminWorkspace() {
> >
Regenerate Regenerate
</Button> </Button>
<Tooltip <Tooltip title={!dirty ? 'No unsaved changes' : ''} disableHoverListener={dirty}>
title={!dirty ? 'No unsaved changes' : ''}
disableHoverListener={dirty}
>
<span> <span>
<Button <Button
variant="contained" variant="contained"
@ -528,12 +616,16 @@ export default function AdminWorkspace() {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setNavBlocked(false)}>Stay</Button> <Button onClick={() => setNavBlocked(false)}>Stay</Button>
<Button variant="contained" color="error" onClick={() => { <Button
setNavBlocked(false); variant="contained"
const dest = pendingNavRef.current ?? '/admin'; color="error"
pendingNavRef.current = null; onClick={() => {
navigate(dest); setNavBlocked(false);
}}> const dest = pendingNavRef.current ?? '/admin';
pendingNavRef.current = null;
navigate(dest);
}}
>
Discard & Leave Discard & Leave
</Button> </Button>
</DialogActions> </DialogActions>
@ -549,11 +641,17 @@ export default function AdminWorkspace() {
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}> <Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
<Typography variant="body2">Line Items: {lineItems.length}</Typography> <Typography variant="body2">Line Items: {lineItems.length}</Typography>
<Typography variant="body2"> <Typography variant="body2">
Total Bid: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(bidTotal)} Total Bid:{' '}
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
bidTotal,
)}
</Typography> </Typography>
{proposal.vendorTotalCost != null && ( {proposal.vendorTotalCost != null && (
<Typography variant="body2"> <Typography variant="body2">
Vendor Cost: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(proposal.vendorTotalCost)} Vendor Cost:{' '}
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
proposal.vendorTotalCost,
)}
</Typography> </Typography>
)} )}
</Box> </Box>
@ -576,8 +674,8 @@ export default function AdminWorkspace() {
<DialogTitle>Mark as Sent</DialogTitle> <DialogTitle>Mark as Sent</DialogTitle>
<DialogContent> <DialogContent>
<Typography> <Typography>
Mark <strong>{proposal.proposalNumber}</strong> as sent to {proposal.customerName}? Mark <strong>{proposal.proposalNumber}</strong> as sent to {proposal.customerName}? This
This action cannot be undone. action cannot be undone.
</Typography> </Typography>
{/* PR4-B: surface delivery behavior — server emails PDF if customer has a contact email */} {/* PR4-B: surface delivery behavior — server emails PDF if customer has a contact email */}
<Alert severity="info" sx={{ mt: 2 }}> <Alert severity="info" sx={{ mt: 2 }}>
@ -602,8 +700,8 @@ export default function AdminWorkspace() {
<DialogTitle>Create Revision</DialogTitle> <DialogTitle>Create Revision</DialogTitle>
<DialogContent> <DialogContent>
<Typography> <Typography>
Create a new revision of <strong>{proposal.proposalNumber}</strong>? Create a new revision of <strong>{proposal.proposalNumber}</strong>? The current version
The current version will be marked as revised and a new editable copy will be created. will be marked as revised and a new editable copy will be created.
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
@ -623,8 +721,8 @@ export default function AdminWorkspace() {
<DialogTitle>Return to Review</DialogTitle> <DialogTitle>Return to Review</DialogTitle>
<DialogContent> <DialogContent>
<Typography> <Typography>
Return <strong>{proposal.proposalNumber}</strong> to In Review status? Return <strong>{proposal.proposalNumber}</strong> to In Review status? This will clear
This will clear the approval and allow further edits to line items and scope. the approval and allow further edits to line items and scope.
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>

View file

@ -134,7 +134,9 @@ export default function LoginPage() {
> >
<Box sx={{ textAlign: 'left', flex: 1 }}> <Box sx={{ textAlign: 'left', flex: 1 }}>
<Box>SysAdmin</Box> <Box>SysAdmin</Box>
<Box sx={{ fontSize: 'var(--text-xs)', fontWeight: 400, opacity: 0.8 }}>Full access</Box> <Box sx={{ fontSize: 'var(--text-xs)', fontWeight: 400, opacity: 0.8 }}>
Full access
</Box>
</Box> </Box>
</Button> </Button>
<Button <Button
@ -147,7 +149,9 @@ export default function LoginPage() {
> >
<Box sx={{ textAlign: 'left', flex: 1 }}> <Box sx={{ textAlign: 'left', flex: 1 }}>
<Box>Admin</Box> <Box>Admin</Box>
<Box sx={{ fontSize: 'var(--text-xs)', fontWeight: 400, opacity: 0.8 }}>Review, price, and approve proposals</Box> <Box sx={{ fontSize: 'var(--text-xs)', fontWeight: 400, opacity: 0.8 }}>
Review, price, and approve proposals
</Box>
</Box> </Box>
</Button> </Button>
<Button <Button
@ -160,7 +164,9 @@ export default function LoginPage() {
> >
<Box sx={{ textAlign: 'left', flex: 1 }}> <Box sx={{ textAlign: 'left', flex: 1 }}>
<Box>Dispatcher</Box> <Box>Dispatcher</Box>
<Box sx={{ fontSize: 'var(--text-xs)', fontWeight: 400, opacity: 0.8 }}>Submit and track proposals</Box> <Box sx={{ fontSize: 'var(--text-xs)', fontWeight: 400, opacity: 0.8 }}>
Submit and track proposals
</Box>
</Box> </Box>
</Button> </Button>
</Stack> </Stack>

View file

@ -28,19 +28,55 @@ import { STATUS_CHIP_STYLES } from '../../constants';
import { formatCurrency, formatDate, formatDateFull } from '../../lib/format'; import { formatCurrency, formatDate, formatDateFull } from '../../lib/format';
import { useDocumentTitle } from '../../hooks/useDocumentTitle'; import { useDocumentTitle } from '../../hooks/useDocumentTitle';
function KpiCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) { function KpiCard({
icon,
label,
value,
subtitle,
}: {
icon: React.ReactNode;
label: string;
value: string;
subtitle?: string;
}) {
return ( return (
<Card sx={{ borderRadius: '8px', transition: 'border-color 120ms ease', '&:hover': { borderColor: '#9CA3AF' } }}> <Card
sx={{
borderRadius: '8px',
transition: 'border-color 120ms ease',
'&:hover': { borderColor: '#9CA3AF' },
}}
>
<CardContent sx={{ textAlign: 'center', py: 2.5 }}> <CardContent sx={{ textAlign: 'center', py: 2.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1, color: 'var(--primary)' }}>{icon}</Box> <Box sx={{ display: 'flex', justifyContent: 'center', mb: 1, color: 'var(--primary)' }}>
<Typography sx={{ fontSize: '12px', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, mb: 1 }}> {icon}
</Box>
<Typography
sx={{
fontSize: '12px',
color: '#64748B',
textTransform: 'uppercase',
letterSpacing: '0.06em',
fontWeight: 600,
mb: 1,
}}
>
{label} {label}
</Typography> </Typography>
<Typography sx={{ fontSize: '30px', fontWeight: 700, color: 'var(--foreground)', letterSpacing: '-0.02em' }}> <Typography
sx={{
fontSize: '30px',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.02em',
}}
>
{value} {value}
</Typography> </Typography>
{subtitle && ( {subtitle && (
<Typography sx={{ fontSize: '11px', color: '#94A3B8', fontWeight: 500, marginTop: '4px' }}> <Typography
sx={{ fontSize: '11px', color: '#94A3B8', fontWeight: 500, marginTop: '4px' }}
>
{subtitle} {subtitle}
</Typography> </Typography>
)} )}
@ -53,10 +89,19 @@ export default function Dashboard() {
useDocumentTitle('Dashboard'); useDocumentTitle('Dashboard');
const navigate = useNavigate(); const navigate = useNavigate();
const { data: recentData, isLoading: recentLoading, isError: recentError } = useProposals({ page: 1, pageSize: 5, mine: true }); const {
data: recentData,
isLoading: recentLoading,
isError: recentError,
} = useProposals({ page: 1, pageSize: 5, mine: true });
// Fix: WEB-M8 — capture error state for stats to show error instead of misleading zeros // Fix: WEB-M8 — capture error state for stats to show error instead of misleading zeros
const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useProposalStats(); const {
data: stats,
isLoading: statsLoading,
isError: statsError,
refetch: refetchStats,
} = useProposalStats();
const proposals = recentData?.items ?? []; const proposals = recentData?.items ?? [];
const total = stats?.totalCount ?? 0; const total = stats?.totalCount ?? 0;
@ -101,28 +146,48 @@ export default function Dashboard() {
{statsLoading ? ( {statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} /> <Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : ( ) : (
<KpiCard icon={<DescriptionIcon fontSize="large" />} label="Total Submitted" value={String(total)} subtitle="All time" /> <KpiCard
icon={<DescriptionIcon fontSize="large" />}
label="Total Submitted"
value={String(total)}
subtitle="All time"
/>
)} )}
</Grid> </Grid>
<Grid size={{ xs: 6, sm: 3 }}> <Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? ( {statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} /> <Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : ( ) : (
<KpiCard icon={<HourglassTopIcon fontSize="large" />} label="In Review" value={String(counts.inReview)} subtitle="Awaiting pricing" /> <KpiCard
icon={<HourglassTopIcon fontSize="large" />}
label="In Review"
value={String(counts.inReview)}
subtitle="Awaiting pricing"
/>
)} )}
</Grid> </Grid>
<Grid size={{ xs: 6, sm: 3 }}> <Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? ( {statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} /> <Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : ( ) : (
<KpiCard icon={<CheckCircleIcon fontSize="large" />} label="Approved" value={String(counts.approved)} subtitle="Ready to send" /> <KpiCard
icon={<CheckCircleIcon fontSize="large" />}
label="Approved"
value={String(counts.approved)}
subtitle="Ready to send"
/>
)} )}
</Grid> </Grid>
<Grid size={{ xs: 6, sm: 3 }}> <Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? ( {statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} /> <Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : ( ) : (
<KpiCard icon={<SendIcon fontSize="large" />} label="Sent" value={String(counts.sent)} subtitle="Delivered to customer" /> <KpiCard
icon={<SendIcon fontSize="large" />}
label="Sent"
value={String(counts.sent)}
subtitle="Delivered to customer"
/>
)} )}
</Grid> </Grid>
</> </>
@ -131,7 +196,9 @@ export default function Dashboard() {
<Card> <Card>
<CardContent sx={{ pb: 1 }}> <CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}
>
<Typography variant="h6">Recent Proposals</Typography> <Typography variant="h6">Recent Proposals</Typography>
<Button size="small" onClick={() => navigate('/proposals')}> <Button size="small" onClick={() => navigate('/proposals')}>
View All View All
@ -155,7 +222,9 @@ export default function Dashboard() {
? Array.from({ length: 5 }).map((_, i) => ( ? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}> <TableRow key={i}>
{Array.from({ length: 6 }).map((_, j) => ( {Array.from({ length: 6 }).map((_, j) => (
<TableCell key={j}><Skeleton variant="text" /></TableCell> <TableCell key={j}>
<Skeleton variant="text" />
</TableCell>
))} ))}
</TableRow> </TableRow>
)) ))
@ -170,7 +239,11 @@ export default function Dashboard() {
<TableCell>{row.customerName}</TableCell> <TableCell>{row.customerName}</TableCell>
<TableCell>{row.serviceCategory}</TableCell> <TableCell>{row.serviceCategory}</TableCell>
<TableCell> <TableCell>
<Chip label={row.status} size="small" sx={STATUS_CHIP_STYLES[row.status]} /> <Chip
label={row.status}
size="small"
sx={STATUS_CHIP_STYLES[row.status]}
/>
</TableCell> </TableCell>
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell> <TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
<TableCell> <TableCell>

View file

@ -73,11 +73,7 @@ export default function ProposalDetailPage() {
<Typography variant="h5" sx={{ flexGrow: 1 }}> <Typography variant="h5" sx={{ flexGrow: 1 }}>
{proposal.proposalNumber} {proposal.proposalNumber}
</Typography> </Typography>
<Chip <Chip label={proposal.status} size="medium" sx={STATUS_CHIP_STYLES[proposal.status]} />
label={proposal.status}
size="medium"
sx={STATUS_CHIP_STYLES[proposal.status]}
/>
</Box> </Box>
<Card sx={{ mb: 3 }}> <Card sx={{ mb: 3 }}>
@ -87,7 +83,10 @@ export default function ProposalDetailPage() {
</Typography> </Typography>
{proposal.status === 'Revised' ? ( {proposal.status === 'Revised' ? (
<Box sx={{ textAlign: 'center', py: 1 }}> <Box sx={{ textAlign: 'center', py: 1 }}>
<Chip label="Revised — a new revision has been created" sx={STATUS_CHIP_STYLES['Revised']} /> <Chip
label="Revised — a new revision has been created"
sx={STATUS_CHIP_STYLES['Revised']}
/>
</Box> </Box>
) : ( ) : (
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel> <Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>

View file

@ -25,7 +25,12 @@ import {
} from '../../../domain/proposals/schemas'; } from '../../../domain/proposals/schemas';
import { useSiteSearch } from '../../../domain/sites/use-cases'; import { useSiteSearch } from '../../../domain/sites/use-cases';
import { useCustomerSearch } from '../../../domain/customers/use-cases'; import { useCustomerSearch } from '../../../domain/customers/use-cases';
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS, DEBOUNCE_AUTOCOMPLETE } from '../../../constants'; import {
SERVICE_CATEGORIES,
PRIORITIES,
PRIORITY_LABELS,
DEBOUNCE_AUTOCOMPLETE,
} from '../../../constants';
import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
@ -34,13 +39,7 @@ const ALLOWED_MIME_TYPES = ['application/pdf'];
export default function ProposalFormPage() { export default function ProposalFormPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { const { register, handleSubmit, control, setValue, watch } = useForm<CreateProposalFormValues>({
register,
handleSubmit,
control,
setValue,
watch,
} = useForm<CreateProposalFormValues>({
resolver: zodResolver(createProposalFormSchema), resolver: zodResolver(createProposalFormSchema),
defaultValues: { defaultValues: {
workOrderNumber: '', workOrderNumber: '',
@ -91,9 +90,7 @@ export default function ProposalFormPage() {
].filter(Boolean); ].filter(Boolean);
const address = addressParts.join(', '); const address = addressParts.join(', ');
const display = const display =
manualSiteCode && address manualSiteCode && address ? `${manualSiteCode} — ${address}` : manualSiteCode || address;
? `${manualSiteCode} — ${address}`
: manualSiteCode || address;
setValue('customerAddress', display); setValue('customerAddress', display);
}, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip, setValue]); }, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip, setValue]);
@ -106,7 +103,7 @@ export default function ProposalFormPage() {
onSuccess: (proposal) => { onSuccess: (proposal) => {
navigate(`/proposals/${proposal.id}`); navigate(`/proposals/${proposal.id}`);
}, },
} },
); );
}; };
@ -135,12 +132,21 @@ export default function ProposalFormPage() {
{ label: 'Specify category', ok: serviceCategory !== 'Other' || !!customCategory.trim() }, { label: 'Specify category', ok: serviceCategory !== 'Other' || !!customCategory.trim() },
{ {
// Fix: WEB-M3 — show minimum length requirement in missing fields summary // Fix: WEB-M3 — show minimum length requirement in missing fields summary
label: !scopeOfWork.trim() ? 'Scope of Work' : `Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`, label: !scopeOfWork.trim()
? 'Scope of Work'
: `Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`,
ok: scopeOfWork.trim().length >= MIN_SCOPE_LENGTH, ok: scopeOfWork.trim().length >= MIN_SCOPE_LENGTH,
}, },
]; ];
return checks.filter((c) => !c.ok).map((c) => c.label); return checks.filter((c) => !c.ok).map((c) => c.label);
}, [workOrderNumber, customerName, customerAddress, serviceCategory, scopeOfWork, customCategory]); }, [
workOrderNumber,
customerName,
customerAddress,
serviceCategory,
scopeOfWork,
customCategory,
]);
const isValid = missingFields.length === 0; const isValid = missingFields.length === 0;
@ -153,7 +159,9 @@ export default function ProposalFormPage() {
<Box component="form" onSubmit={handleSubmit(onSubmit)}> <Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Card sx={{ mb: 2 }}> <Card sx={{ mb: 2 }}>
<CardContent> <CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Job Details</Typography> <Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Job Details
</Typography>
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField <TextField
@ -164,11 +172,7 @@ export default function ProposalFormPage() {
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField <TextField label="Purchase Order Number" fullWidth {...register('poNumber')} />
label="Purchase Order Number"
fullWidth
{...register('poNumber')}
/>
</Grid> </Grid>
{/* PR4: freeSolo customer search — dispatchers can pick an existing {/* PR4: freeSolo customer search — dispatchers can pick an existing
customer or type a new name (customerName is a free string). */} customer or type a new name (customerName is a free string). */}
@ -191,7 +195,7 @@ export default function ProposalFormPage() {
} }
}} }}
onChange={(_, value) => { onChange={(_, value) => {
const selected = typeof value === 'string' ? value : value ?? ''; const selected = typeof value === 'string' ? value : (value ?? '');
setValue('customerName', selected); setValue('customerName', selected);
setCustomerInputValue(selected); setCustomerInputValue(selected);
}} }}
@ -252,7 +256,9 @@ export default function ProposalFormPage() {
<Card sx={{ mb: 2 }}> <Card sx={{ mb: 2 }}>
<CardContent> <CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Site & Location</Typography> <Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Site & Location
</Typography>
{manualEntry ? ( {manualEntry ? (
<Box> <Box>
<Grid container spacing={2}> <Grid container spacing={2}>
@ -327,9 +333,7 @@ export default function ProposalFormPage() {
options={siteOptions} options={siteOptions}
filterOptions={(x) => x} filterOptions={(x) => x}
getOptionLabel={(opt) => getOptionLabel={(opt) =>
opt.fullAddress opt.fullAddress ? `${opt.siteCode} — ${opt.fullAddress}` : opt.siteCode
? `${opt.siteCode} — ${opt.fullAddress}`
: opt.siteCode
} }
loading={siteLoading} loading={siteLoading}
inputValue={siteInputValue} inputValue={siteInputValue}
@ -394,7 +398,9 @@ export default function ProposalFormPage() {
<Card sx={{ mb: 2 }}> <Card sx={{ mb: 2 }}>
<CardContent> <CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Work Details</Typography> <Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Work Details
</Typography>
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6 }}> <Grid size={{ xs: 12, sm: 6 }}>
<Controller <Controller
@ -476,13 +482,18 @@ export default function ProposalFormPage() {
setVendorFile(null); setVendorFile(null);
return; return;
} }
if (!file.name.toLowerCase().endsWith('.pdf') || !ALLOWED_MIME_TYPES.includes(file.type)) { if (
!file.name.toLowerCase().endsWith('.pdf') ||
!ALLOWED_MIME_TYPES.includes(file.type)
) {
toast.error('Only PDF files are accepted.'); toast.error('Only PDF files are accepted.');
e.target.value = ''; e.target.value = '';
return; return;
} }
if (file.size > MAX_FILE_SIZE_BYTES) { if (file.size > MAX_FILE_SIZE_BYTES) {
toast.error('File size exceeds the 25 MB limit. Please choose a smaller file.'); toast.error(
'File size exceeds the 25 MB limit. Please choose a smaller file.',
);
e.target.value = ''; e.target.value = '';
return; return;
} }

View file

@ -22,7 +22,12 @@ import SearchIcon from '@mui/icons-material/Search';
import AddCircleIcon from '@mui/icons-material/AddCircle'; import AddCircleIcon from '@mui/icons-material/AddCircle';
import { useProposals } from '../../../domain/proposals/use-cases'; import { useProposals } from '../../../domain/proposals/use-cases';
import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
import { STATUS_CHIP_STYLES, DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../../../constants'; import {
STATUS_CHIP_STYLES,
DEBOUNCE_SEARCH,
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
} from '../../../constants';
import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format'; import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
@ -133,13 +138,22 @@ export default function ProposalListPage() {
/> />
</TableCell> </TableCell>
<TableCell align="right"> <TableCell align="right">
{row.totalBidAmount > 0 {row.totalBidAmount > 0 ? (
? formatBidAmount(row.totalBidAmount) formatBidAmount(row.totalBidAmount)
: <Typography variant="body2" component="span" sx={{ color: 'var(--color-text-muted)' }}>Not priced</Typography> ) : (
} <Typography
variant="body2"
component="span"
sx={{ color: 'var(--color-text-muted)' }}
>
Not priced
</Typography>
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<span title={formatDateFull(row.submittedAt)}>{formatDate(row.submittedAt)}</span> <span title={formatDateFull(row.submittedAt)}>
{formatDate(row.submittedAt)}
</span>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}

View file

@ -19,10 +19,12 @@ import { STORAGE_KEY_TOKEN } from '../../constants';
function createToken(expOffsetSeconds: number): string { function createToken(expOffsetSeconds: number): string {
const header = btoa(JSON.stringify({ alg: 'HS256' })); const header = btoa(JSON.stringify({ alg: 'HS256' }));
const payload = btoa(JSON.stringify({ const payload = btoa(
sub: 'test-user', JSON.stringify({
exp: Math.floor(Date.now() / 1000) + expOffsetSeconds, sub: 'test-user',
})); exp: Math.floor(Date.now() / 1000) + expOffsetSeconds,
}),
);
return `${header}.${payload}.fake-signature`; return `${header}.${payload}.fake-signature`;
} }

View file

@ -1,12 +1,12 @@
/* Self-hosted fonts (@fontsource) — same weights as shoc-frontend-new dev. */ /* Self-hosted fonts (@fontsource) — same weights as shoc-frontend-new dev. */
@import "@fontsource/montserrat/600.css"; @import '@fontsource/montserrat/600.css';
@import "@fontsource/montserrat/700.css"; @import '@fontsource/montserrat/700.css';
@import "@fontsource/montserrat/800.css"; @import '@fontsource/montserrat/800.css';
@import "@fontsource/dm-sans/400.css"; @import '@fontsource/dm-sans/400.css';
@import "@fontsource/dm-sans/500.css"; @import '@fontsource/dm-sans/500.css';
@import "@fontsource/dm-sans/600.css"; @import '@fontsource/dm-sans/600.css';
@import "@fontsource/jetbrains-mono/400.css"; @import '@fontsource/jetbrains-mono/400.css';
@import "@fontsource/jetbrains-mono/500.css"; @import '@fontsource/jetbrains-mono/500.css';
@import "@fontsource/jetbrains-mono/600.css"; @import '@fontsource/jetbrains-mono/600.css';

View file

@ -1,5 +1,5 @@
/* Global stylesheet — import order mirrors shoc-frontend-new dev (minus Tailwind). */ /* Global stylesheet — import order mirrors shoc-frontend-new dev (minus Tailwind). */
@import "./fonts.css"; @import './fonts.css';
@import "./theme.css"; @import './theme.css';
@import "./typography.css"; @import './typography.css';
@import "./motion.css"; @import './motion.css';

View file

@ -9,9 +9,9 @@
--font-size: 14px; --font-size: 14px;
/* ── TYPOGRAPHY ─────────────────────────────────────────────────── */ /* ── TYPOGRAPHY ─────────────────────────────────────────────────── */
--font-family-display: "Montserrat", ui-sans-serif, system-ui, sans-serif; --font-family-display: 'Montserrat', ui-sans-serif, system-ui, sans-serif;
--font-family-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif; --font-family-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
--font-family-mono: "JetBrains Mono", ui-monospace, monospace; --font-family-mono: 'JetBrains Mono', ui-monospace, monospace;
--font-display: var(--font-family-display); --font-display: var(--font-family-display);
--font-sans: var(--font-family-sans); --font-sans: var(--font-family-sans);
--font-mono: var(--font-family-mono); --font-mono: var(--font-family-mono);

View file

@ -12,18 +12,41 @@ body {
background-color: var(--color-bg-page); background-color: var(--color-bg-page);
} }
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading); font-family: var(--font-heading);
color: var(--foreground); color: var(--foreground);
margin: 0; margin: 0;
} }
h1 { font-size: var(--text-2xl); font-weight: 800; } h1 {
h2 { font-size: var(--text-xl); font-weight: 700; } font-size: var(--text-2xl);
h3 { font-size: var(--text-lg); font-weight: 600; } font-weight: 800;
h4 { font-size: var(--text-md); font-weight: 600; } }
h5 { font-size: var(--text-base); font-weight: 600; } h2 {
h6 { font-size: var(--text-base-sm); font-weight: 600; } font-size: var(--text-xl);
font-weight: 700;
}
h3 {
font-size: var(--text-lg);
font-weight: 600;
}
h4 {
font-size: var(--text-md);
font-weight: 600;
}
h5 {
font-size: var(--text-base);
font-weight: 600;
}
h6 {
font-size: var(--text-base-sm);
font-weight: 600;
}
.text-caption { .text-caption {
font-family: var(--font-sans); font-family: var(--font-sans);