workspace: sticky panels, action bar polish, breadcrumb nav, line item UX improvements

This commit is contained in:
Adam Moussa 2026-05-22 18:57:49 -04:00
parent 0c8ab31282
commit d15f6bfb95
2 changed files with 138 additions and 50 deletions

View file

@ -15,6 +15,7 @@ import {
Tooltip, Tooltip,
} from '@mui/material'; } from '@mui/material';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
@ -88,6 +89,25 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
onChange(updated.map((item, i) => ({ ...item, sortOrder: i + 1 }))); onChange(updated.map((item, i) => ({ ...item, sortOrder: i + 1 })));
}; };
const duplicateItem = (index: number) => {
const source = items[index];
if (!source) return;
const newItem: EditableLineItem = {
id: null,
description: source.description,
quantity: source.quantity,
unit: source.unit,
unitPrice: source.unitPrice,
totalPrice: source.totalPrice,
pricingMode: source.pricingMode,
sortOrder: 0,
source: 'Manual',
};
const updated = [...items];
updated.splice(index + 1, 0, newItem);
onChange(updated.map((item, i) => ({ ...item, sortOrder: i + 1 })));
};
const moveItem = (index: number, direction: -1 | 1) => { const moveItem = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction; const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= items.length) return; if (targetIndex < 0 || targetIndex >= items.length) return;
@ -109,8 +129,8 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
Line Items ({items.length}) Line Items ({items.length})
</Typography> </Typography>
{!disabled && ( {!disabled && (
<Button size="small" startIcon={<AddIcon />} onClick={addItem}> <Button size="small" variant={items.length === 0 ? 'contained' : 'outlined'} startIcon={<AddIcon />} onClick={addItem}>
Add Row Add Line Item
</Button> </Button>
)} )}
</Box> </Box>
@ -126,7 +146,7 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
<TableCell sx={{ width: 110 }}>Unit Price</TableCell> <TableCell sx={{ width: 110 }}>Unit Price</TableCell>
<TableCell sx={{ width: 110 }}>Total</TableCell> <TableCell sx={{ width: 110 }}>Total</TableCell>
<TableCell sx={{ width: 90 }}>Source</TableCell> <TableCell sx={{ width: 90 }}>Source</TableCell>
{!disabled && <TableCell sx={{ width: 100 }}>Actions</TableCell>} {!disabled && <TableCell sx={{ width: 120 }}>Actions</TableCell>}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@ -217,6 +237,11 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
</TableCell> </TableCell>
{!disabled && ( {!disabled && (
<TableCell> <TableCell>
<Tooltip title="Duplicate">
<IconButton size="small" onClick={() => duplicateItem(index)}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Remove"> <Tooltip title="Remove">
<IconButton size="small" color="error" onClick={() => removeItem(index)}> <IconButton size="small" color="error" onClick={() => removeItem(index)}>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
@ -228,10 +253,16 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
))} ))}
{items.length === 0 && ( {items.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={disabled ? 7 : 8} align="center" sx={{ py: 3 }}> <TableCell colSpan={disabled ? 7 : 8} align="center" sx={{ py: 5 }}>
<Typography color="text.secondary"> <Typography variant="subtitle2">No line items yet</Typography>
No line items yet. {!disabled && 'Click "Add Row" to start.'} <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 2 }}>
Add labor, material, equipment, or other cost items.
</Typography> </Typography>
{!disabled && (
<Button variant="contained" startIcon={<AddIcon />} onClick={addItem}>
Add Line Item
</Button>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@ -241,7 +272,7 @@ 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 }}>
Total: {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

@ -17,10 +17,13 @@ import {
DialogActions, DialogActions,
CircularProgress, CircularProgress,
Alert, Alert,
Tooltip,
Link,
} from '@mui/material'; } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save'; import SaveIcon from '@mui/icons-material/Save';
import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import HistoryIcon from '@mui/icons-material/History'; import HistoryIcon from '@mui/icons-material/History';
@ -59,6 +62,14 @@ export default function AdminWorkspace() {
enabled: !!id, enabled: !!id,
}); });
const { data: similarProposals } = useQuery({
queryKey: ['similarProposals', id],
queryFn: () => adminApi.getSimilar(id!),
enabled: !!id,
});
const hasSimilar = similarProposals && similarProposals.length > 0;
useEffect(() => { useEffect(() => {
if (fetchedLineItems) { if (fetchedLineItems) {
setLineItems( setLineItems(
@ -214,13 +225,21 @@ export default function AdminWorkspace() {
return ( return (
<Box> <Box>
{/* Header */} {/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexGrow: 1 }}>
Back <Link
</Button> component="button"
<Typography variant="h5" sx={{ flexGrow: 1 }}> underline="hover"
{proposal.proposalNumber} onClick={() => navigate('/admin')}
</Typography> sx={{ fontSize: 14, color: '#0B5A73', cursor: 'pointer' }}
>
Admin Queue
</Link>
<NavigateNextIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography sx={{ fontSize: 26, fontWeight: 800 }}>
{proposal.proposalNumber}
</Typography>
</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]} />
</Box> </Box>
@ -255,7 +274,7 @@ 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={{ height: '100%' }}> <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>
@ -290,7 +309,7 @@ export default function AdminWorkspace() {
</Grid> </Grid>
{/* Center Panel: Editor */} {/* Center Panel: Editor */}
<Grid size={{ xs: 12, md: 6 }}> <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>
@ -315,28 +334,30 @@ export default function AdminWorkspace() {
</Card> </Card>
</Grid> </Grid>
{/* Right Panel: Similar Proposals */} {/* Right Panel: Similar Proposals (hidden when empty) */}
<Grid size={{ xs: 12, md: 3 }}> {hasSimilar && (
<Card sx={{ height: '100%' }}> <Grid size={{ xs: 12, md: 3 }}>
<CardContent> <Card sx={{ height: '100%' }}>
<SimilarProposalsPanel <CardContent>
proposalId={id!} <SimilarProposalsPanel
onPullLineItem={(item) => { proposalId={id!}
const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0; onPullLineItem={(item) => {
handleLineItemsChange([ const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0;
...lineItems, handleLineItemsChange([
{ ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' }, ...lineItems,
]); { ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' },
}} ]);
disabled={!isEditable} }}
/> disabled={!isEditable}
</CardContent> />
</Card> </CardContent>
</Grid> </Card>
</Grid>
)}
</Grid> </Grid>
{/* Action Bar */} {/* Action Bar */}
<Card sx={{ mt: 2, position: 'sticky', bottom: 16, zIndex: 10 }}> <Card sx={{ mt: 2, position: 'sticky', bottom: 0, zIndex: 10, bgcolor: 'rgba(255,255,255,0.96)', backdropFilter: 'blur(8px)', borderTop: '1px solid #D6E0EA', boxShadow: '0 -4px 12px rgba(15,23,42,0.06)', border: 'none' }}>
<CardContent sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}> <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>
@ -353,23 +374,59 @@ export default function AdminWorkspace() {
> >
Regenerate Regenerate
</Button> </Button>
<Button <Tooltip
variant="contained" title={!dirty ? 'No unsaved changes' : ''}
startIcon={<SaveIcon />} disableHoverListener={dirty}
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !dirty}
> >
Save <span>
</Button> <Button
<Button variant="contained"
variant="contained" startIcon={<SaveIcon />}
color="success" onClick={() => saveMutation.mutate()}
startIcon={<CheckCircleIcon />} disabled={saveMutation.isPending || !dirty}
onClick={() => setApproveDialogOpen(true)} >
disabled={lineItems.length === 0 || bidTotal <= 0} Save
</Button>
</span>
</Tooltip>
<Tooltip
title={lineItems.length === 0 ? 'Add at least one line item' : ''}
disableHoverListener={lineItems.length > 0 && bidTotal > 0}
> >
Approve <span>
</Button> <Button
variant="contained"
color="success"
startIcon={<CheckCircleIcon />}
onClick={() => setApproveDialogOpen(true)}
disabled={lineItems.length === 0 || bidTotal <= 0}
>
Approve &amp; Send
</Button>
</span>
</Tooltip>
<Tooltip
title={lineItems.length === 0 ? 'Add at least one line item' : ''}
disableHoverListener={lineItems.length > 0 && bidTotal > 0}
>
<span>
<Button
variant="outlined"
color="success"
startIcon={<CheckCircleIcon />}
onClick={() => {
approveMutation.mutate(undefined, {
onSuccess: () => {
navigate('/admin');
},
});
}}
disabled={lineItems.length === 0 || bidTotal <= 0 || approveMutation.isPending}
>
Approve &amp; Next
</Button>
</span>
</Tooltip>
</> </>
)} )}