mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 05:23: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,
|
||||
} 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>
|
||||
|
|
|
|||
|
|
@ -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 & 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