mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 07:43:14 +00:00
workspace: sticky panels, action bar polish, breadcrumb nav, line item UX improvements
This commit is contained in:
parent
0c8ab31282
commit
d15f6bfb95
2 changed files with 138 additions and 50 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 & 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 & Next
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue