proposal-system/web/src/components/admin/LineItemEditor.tsx
Adam Moussa 9d856a9619
Some checks failed
Deploy / Deploy to AWS (push) Has been cancelled
Phase 3 audit fixes: FIX-01–47, accessibility NITs, code quality NITs [skip deploy]
## Summary
Implements Phase 3 of the AUDIT-2026-05-20 findings:
- 29 FIX-severity items across API, web, infra, and lambdas
- 7 accessibility NITs (aria-labels, document titles)
- 4 code quality NITs (deduplication, constants extraction)

Key changes:
- API: N+1 fix, pagination clamping, idempotent transitions, upload confirm endpoint, revision TotalBidAmount carry-forward
- Web: confirmation dialogs, currency formatting, error states, date range filters, document titles
- Infra: S3 CORS lockdown, API Gateway throttling, AOSS network policy fix, CI concurrency
- Lambdas: skip empty suggestions, remove status side-effect
- Scripts: post-deploy health check

## Test plan
- [x] tsc --noEmit (web + infra)
- [x] dotnet build (api)
- [x] ruff check + format (lambdas)
- [x] Cross-review via orchestrator (no blockers)

[skip deploy]
2026-05-20 19:38:36 -04:00

249 lines
8.8 KiB
TypeScript

import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
IconButton,
MenuItem,
Chip,
Typography,
Button,
Tooltip,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import type { PricingMode, LineItemSource } from '../../lib/api/lineItems';
export interface EditableLineItem {
id: string | null;
description: string;
quantity: number;
unit: string;
unitPrice: number | null;
totalPrice: number;
pricingMode: PricingMode;
sortOrder: number;
source: LineItemSource;
}
interface LineItemEditorProps {
items: EditableLineItem[];
onChange: (items: EditableLineItem[]) => void;
disabled?: boolean;
}
const SOURCE_COLORS: Record<LineItemSource, 'primary' | 'secondary' | 'default' | 'success'> = {
AI: 'primary',
Vendor: 'secondary',
Manual: 'default',
Historical: 'success',
};
const UNITS = ['each', 'sq ft', 'linear ft', 'hours', 'days', 'lot', 'gallon', 'ton'];
function createEmptyItem(sortOrder: number): EditableLineItem {
return {
id: null,
description: '',
quantity: 1,
unit: 'each',
unitPrice: 0,
totalPrice: 0,
pricingMode: 'UnitPrice',
sortOrder,
source: 'Manual',
};
}
export default function LineItemEditor({ items, onChange, disabled = false }: LineItemEditorProps) {
const updateItem = (index: number, field: keyof EditableLineItem, value: unknown) => {
const updated = [...items];
const current = updated[index];
if (!current) return;
updated[index] = { ...current, [field]: value };
if (field === 'quantity' || field === 'unitPrice') {
const item = updated[index]!;
if (item.pricingMode === 'UnitPrice' || item.pricingMode === 'Both') {
item.totalPrice = (item.quantity || 0) * (item.unitPrice || 0);
}
}
onChange(updated);
};
const addItem = () => {
const maxSort = items.length > 0 ? Math.max(...items.map((i) => i.sortOrder)) : 0;
onChange([...items, createEmptyItem(maxSort + 1)]);
};
const removeItem = (index: number) => {
const updated = items.filter((_, i) => i !== index);
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;
const updated = [...items];
const a = updated[index];
const b = updated[targetIndex];
if (!a || !b) return;
updated[index] = b;
updated[targetIndex] = a;
onChange(updated.map((item, i) => ({ ...item, sortOrder: i + 1 })));
};
const subtotal = items.reduce((sum, item) => sum + item.totalPrice, 0);
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2">
Line Items ({items.length})
</Typography>
{!disabled && (
<Button size="small" startIcon={<AddIcon />} onClick={addItem}>
Add Row
</Button>
)}
</Box>
<TableContainer sx={{ maxHeight: 480 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ width: 60 }}>#</TableCell>
<TableCell sx={{ minWidth: 200 }}>Description</TableCell>
<TableCell sx={{ width: 80 }}>Qty</TableCell>
<TableCell sx={{ width: 100 }}>Unit</TableCell>
<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>}
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => (
<TableRow key={item.id ?? `new-${index}`} hover>
<TableCell>
{!disabled && (
<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`}>
<ArrowUpwardIcon sx={{ fontSize: 14 }} />
</IconButton>
<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`}>
<ArrowDownwardIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
)}
{disabled && <Typography variant="caption">{index + 1}</Typography>}
</TableCell>
<TableCell>
<TextField
size="small"
fullWidth
multiline
maxRows={3}
value={item.description}
onChange={(e) => updateItem(index, 'description', e.target.value)}
disabled={disabled}
variant="standard"
/>
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={item.quantity}
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
disabled={disabled}
variant="standard"
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
/>
</TableCell>
<TableCell>
<TextField
size="small"
select
value={item.unit}
onChange={(e) => updateItem(index, 'unit', e.target.value)}
disabled={disabled}
variant="standard"
fullWidth
>
{UNITS.map((u) => (
<MenuItem key={u} value={u}>{u}</MenuItem>
))}
</TextField>
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={item.unitPrice ?? ''}
onChange={(e) => updateItem(index, 'unitPrice', parseFloat(e.target.value) || 0)}
disabled={disabled || item.pricingMode === 'TotalPrice'}
variant="standard"
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
/>
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={item.totalPrice}
onChange={(e) => updateItem(index, 'totalPrice', parseFloat(e.target.value) || 0)}
disabled={disabled || item.pricingMode === 'UnitPrice'}
variant="standard"
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
sx={{ fontWeight: 600 }}
/>
</TableCell>
<TableCell>
<Chip
label={item.source}
size="small"
color={SOURCE_COLORS[item.source]}
variant="outlined"
/>
</TableCell>
{!disabled && (
<TableCell>
<Tooltip title="Remove">
<IconButton size="small" color="error" onClick={() => removeItem(index)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
)}
</TableRow>
))}
{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.'}
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<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)}
</Typography>
</Box>
</Box>
);
}