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,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DeleteIcon from '@mui/icons-material/Delete';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
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 })));
};
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 targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= items.length) return;
@ -109,8 +129,8 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
Line Items ({items.length})
</Typography>
{!disabled && (
<Button size="small" startIcon={<AddIcon />} onClick={addItem}>
Add Row
<Button size="small" variant={items.length === 0 ? 'contained' : 'outlined'} startIcon={<AddIcon />} onClick={addItem}>
Add Line Item
</Button>
)}
</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 }}>Total</TableCell>
<TableCell sx={{ width: 90 }}>Source</TableCell>
{!disabled && <TableCell sx={{ width: 100 }}>Actions</TableCell>}
{!disabled && <TableCell sx={{ width: 120 }}>Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody>
@ -217,6 +237,11 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
</TableCell>
{!disabled && (
<TableCell>
<Tooltip title="Duplicate">
<IconButton size="small" onClick={() => duplicateItem(index)}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Remove">
<IconButton size="small" color="error" onClick={() => removeItem(index)}>
<DeleteIcon fontSize="small" />
@ -228,10 +253,16 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={disabled ? 7 : 8} align="center" sx={{ py: 3 }}>
<Typography color="text.secondary">
No line items yet. {!disabled && 'Click "Add Row" to start.'}
<TableCell colSpan={disabled ? 7 : 8} align="center" sx={{ py: 5 }}>
<Typography variant="subtitle2">No line items yet</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 2 }}>
Add labor, material, equipment, or other cost items.
</Typography>
{!disabled && (
<Button variant="contained" startIcon={<AddIcon />} onClick={addItem}>
Add Line Item
</Button>
)}
</TableCell>
</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 }}>
<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>
</Box>
</Box>

View file

@ -17,10 +17,13 @@ import {
DialogActions,
CircularProgress,
Alert,
Tooltip,
Link,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import SendIcon from '@mui/icons-material/Send';
import RefreshIcon from '@mui/icons-material/Refresh';
import HistoryIcon from '@mui/icons-material/History';
@ -59,6 +62,14 @@ export default function AdminWorkspace() {
enabled: !!id,
});
const { data: similarProposals } = useQuery({
queryKey: ['similarProposals', id],
queryFn: () => adminApi.getSimilar(id!),
enabled: !!id,
});
const hasSimilar = similarProposals && similarProposals.length > 0;
useEffect(() => {
if (fetchedLineItems) {
setLineItems(
@ -214,13 +225,21 @@ export default function AdminWorkspace() {
return (
<Box>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')}>
Back
</Button>
<Typography variant="h5" sx={{ flexGrow: 1 }}>
{proposal.proposalNumber}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexGrow: 1 }}>
<Link
component="button"
underline="hover"
onClick={() => navigate('/admin')}
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={proposal.status} sx={STATUS_CHIP_STYLES[proposal.status]} />
</Box>
@ -255,7 +274,7 @@ export default function AdminWorkspace() {
<Grid container spacing={2}>
{/* Left Panel: Reference */}
<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>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Submission Details</Typography>
@ -290,7 +309,7 @@ export default function AdminWorkspace() {
</Grid>
{/* Center Panel: Editor */}
<Grid size={{ xs: 12, md: 6 }}>
<Grid size={{ xs: 12, md: hasSimilar ? 6 : 9 }}>
<Card>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Refined Scope</Typography>
@ -315,28 +334,30 @@ export default function AdminWorkspace() {
</Card>
</Grid>
{/* Right Panel: Similar Proposals */}
<Grid size={{ xs: 12, md: 3 }}>
<Card sx={{ height: '100%' }}>
<CardContent>
<SimilarProposalsPanel
proposalId={id!}
onPullLineItem={(item) => {
const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0;
handleLineItemsChange([
...lineItems,
{ ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' },
]);
}}
disabled={!isEditable}
/>
</CardContent>
</Card>
</Grid>
{/* Right Panel: Similar Proposals (hidden when empty) */}
{hasSimilar && (
<Grid size={{ xs: 12, md: 3 }}>
<Card sx={{ height: '100%' }}>
<CardContent>
<SimilarProposalsPanel
proposalId={id!}
onPullLineItem={(item) => {
const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0;
handleLineItemsChange([
...lineItems,
{ ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' },
]);
}}
disabled={!isEditable}
/>
</CardContent>
</Card>
</Grid>
)}
</Grid>
{/* 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' }}>
{dirty && (
<Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>Unsaved changes</Alert>
@ -353,23 +374,59 @@ export default function AdminWorkspace() {
>
Regenerate
</Button>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !dirty}
<Tooltip
title={!dirty ? 'No unsaved changes' : ''}
disableHoverListener={dirty}
>
Save
</Button>
<Button
variant="contained"
color="success"
startIcon={<CheckCircleIcon />}
onClick={() => setApproveDialogOpen(true)}
disabled={lineItems.length === 0 || bidTotal <= 0}
<span>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !dirty}
>
Save
</Button>
</span>
</Tooltip>
<Tooltip
title={lineItems.length === 0 ? 'Add at least one line item' : ''}
disableHoverListener={lineItems.length > 0 && bidTotal > 0}
>
Approve
</Button>
<span>
<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>
</>
)}