Phase 3 audit fixes: FIX-01–47, accessibility NITs, code quality NITs [skip deploy]
Some checks failed
Deploy / Deploy to AWS (push) Has been cancelled

## 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]
This commit is contained in:
Adam Moussa 2026-05-20 19:38:36 -04:00 • committed by GitHub
parent a199b4675c
commit 9d856a9619
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 289 additions and 93 deletions

View file

@ -3,6 +3,16 @@ name: CI
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
paths-ignore:
- '*.md'
- 'docs/**'
- 'AUDIT-*.md'
- '.claude/**'
- 'LICENSE'
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions: permissions:
contents: read contents: read

View file

@ -29,14 +29,20 @@ public class AdminController : ControllerBase
var approvedThisWeek = await _db.Proposals var approvedThisWeek = await _db.Proposals
.CountAsync(p => p.ApprovedAt >= weekStart, ct); .CountAsync(p => p.ApprovedAt >= weekStart, ct);
var approvedTimes = await _db.Proposals var approvedCount = await _db.Proposals
.Where(p => p.ApprovedAt.HasValue) .CountAsync(p => p.ApprovedAt.HasValue, ct);
.Select(p => new { p.ApprovedAt, p.SubmittedAt })
.ToListAsync(ct);
var avgTurnaround = approvedTimes.Count > 0 double avgTurnaround = 0;
? approvedTimes.Average(p => (p.ApprovedAt!.Value - p.SubmittedAt).TotalHours) if (approvedCount > 0)
: 0; {
var recentApproved = await _db.Proposals
.Where(p => p.ApprovedAt.HasValue)
.OrderByDescending(p => p.ApprovedAt)
.Take(200)
.Select(p => new { p.ApprovedAt, p.SubmittedAt })
.ToListAsync(ct);
avgTurnaround = recentApproved.Average(p => (p.ApprovedAt!.Value - p.SubmittedAt).TotalHours);
}
var totalProposals = await _db.Proposals.CountAsync(ct); var totalProposals = await _db.Proposals.CountAsync(ct);

View file

@ -38,6 +38,7 @@ public class FilesController : ControllerBase
public async Task<ActionResult<PresignedUploadResponse>> UploadAttachment( public async Task<ActionResult<PresignedUploadResponse>> UploadAttachment(
Guid proposalId, Guid proposalId,
[FromQuery] string fileName, [FromQuery] string fileName,
[FromQuery] string? vendorName,
CancellationToken ct) CancellationToken ct)
{ {
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct); var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct);
@ -56,6 +57,7 @@ public class FilesController : ControllerBase
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ProposalId = proposalId, ProposalId = proposalId,
VendorName = vendorName ?? string.Empty,
FileName = fileName, FileName = fileName,
S3Key = s3Key, S3Key = s3Key,
UploadedAt = DateTime.UtcNow, UploadedAt = DateTime.UtcNow,
@ -65,9 +67,30 @@ public class FilesController : ControllerBase
_db.VendorProposals.Add(vendorProposal); _db.VendorProposals.Add(vendorProposal);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
await _jobPublisher.PublishAsync("pdf-extract", new { proposalId, s3Key, vendorProposalId = vendorProposal.Id }, ct); return Ok(new PresignedUploadResponse(url, s3Key, DateTime.UtcNow.AddMinutes(15), vendorProposal.Id));
}
return Ok(new PresignedUploadResponse(url, s3Key, DateTime.UtcNow.AddMinutes(15))); [HttpPost("attachments/{vendorProposalId:guid}/confirm")]
public async Task<ActionResult> ConfirmUpload(
Guid proposalId,
Guid vendorProposalId,
CancellationToken ct)
{
var vendorProposal = await _db.VendorProposals
.FirstOrDefaultAsync(v => v.Id == vendorProposalId && v.ProposalId == proposalId, ct);
if (vendorProposal == null) return NotFound();
if (vendorProposal.ProcessingStatus != ProcessingStatus.Pending)
return Ok();
await _jobPublisher.PublishAsync("pdf-extract", new
{
proposalId,
s3Key = vendorProposal.S3Key,
vendorProposalId = vendorProposal.Id,
}, ct);
return Ok();
} }
[HttpGet("pdf")] [HttpGet("pdf")]

View file

@ -58,10 +58,10 @@ public class GlobalExceptionHandler : IMiddleware
} }
), ),
InvalidOperationException => ( InvalidOperationException => (
HttpStatusCode.Conflict, HttpStatusCode.BadRequest,
new ProblemDetails new ProblemDetails
{ {
Status = 409, Status = 400,
Title = "Invalid Operation", Title = "Invalid Operation",
Detail = exception.Message, Detail = exception.Message,
} }

View file

@ -8,10 +8,12 @@ public class InternalApiKeyMiddleware
{ {
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly byte[] _apiKeyBytes; private readonly byte[] _apiKeyBytes;
private readonly ILogger<InternalApiKeyMiddleware> _logger;
public InternalApiKeyMiddleware(RequestDelegate next, IConfiguration configuration) public InternalApiKeyMiddleware(RequestDelegate next, IConfiguration configuration, ILogger<InternalApiKeyMiddleware> logger)
{ {
_next = next; _next = next;
_logger = logger;
var key = configuration["INTERNAL_API_KEY"] ?? ""; var key = configuration["INTERNAL_API_KEY"] ?? "";
_apiKeyBytes = Encoding.UTF8.GetBytes(key); _apiKeyBytes = Encoding.UTF8.GetBytes(key);
} }
@ -38,6 +40,11 @@ public class InternalApiKeyMiddleware
var identity = new ClaimsIdentity(claims, "InternalApiKey"); var identity = new ClaimsIdentity(claims, "InternalApiKey");
context.User = new ClaimsPrincipal(identity); context.User = new ClaimsPrincipal(identity);
} }
else
{
_logger.LogWarning("Invalid internal API key from {RemoteIp} on {Path}",
context.Connection.RemoteIpAddress, context.Request.Path);
}
} }
await _next(context); await _next(context);

View file

@ -157,9 +157,10 @@ builder.Services.AddCors(options =>
{ {
options.AddDefaultPolicy(policy => options.AddDefaultPolicy(policy =>
{ {
policy.WithOrigins( var origins = new List<string> { "https://proposals.seahaven.com" };
"https://proposals.seahaven.com", if (builder.Environment.IsDevelopment())
"http://localhost:5173") origins.Add("http://localhost:5173");
policy.WithOrigins(origins.ToArray())
.AllowAnyMethod() .AllowAnyMethod()
.AllowAnyHeader(); .AllowAnyHeader();
}); });

View file

@ -3,7 +3,8 @@ namespace ProposalSystem.Application.DTOs;
public record PresignedUploadResponse( public record PresignedUploadResponse(
string UploadUrl, string UploadUrl,
string S3Key, string S3Key,
DateTime ExpiresAt DateTime ExpiresAt,
Guid VendorProposalId
); );
public record PdfDownloadResponse( public record PdfDownloadResponse(

View file

@ -57,9 +57,17 @@ public class ProposalService : IProposalService
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct); try
{
await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct);
}
catch { }
await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct); try
{
await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct);
}
catch { }
return MapToResponse(proposal); return MapToResponse(proposal);
} }
@ -80,6 +88,9 @@ public class ProposalService : IProposalService
public async Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default) public async Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default)
{ {
var page = Math.Max(1, filter.Page);
var pageSize = Math.Clamp(filter.PageSize, 1, 100);
var query = _db.Proposals var query = _db.Proposals
.Include(p => p.SubmittedBy) .Include(p => p.SubmittedBy)
.Include(p => p.AssignedAdmin) .Include(p => p.AssignedAdmin)
@ -119,8 +130,8 @@ public class ProposalService : IProposalService
var items = await query var items = await query
.OrderByDescending(p => p.Priority) .OrderByDescending(p => p.Priority)
.ThenByDescending(p => p.SubmittedAt) .ThenByDescending(p => p.SubmittedAt)
.Skip((filter.Page - 1) * filter.PageSize) .Skip((page - 1) * pageSize)
.Take(filter.PageSize) .Take(pageSize)
.Select(p => new ProposalListResponse( .Select(p => new ProposalListResponse(
p.Id, p.Id,
p.ProposalNumber, p.ProposalNumber,
@ -136,7 +147,7 @@ public class ProposalService : IProposalService
)) ))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResponse<ProposalListResponse>(items, totalCount, filter.Page, filter.PageSize); return new PagedResponse<ProposalListResponse>(items, totalCount, page, pageSize);
} }
public async Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default) public async Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default)
@ -157,8 +168,8 @@ public class ProposalService : IProposalService
if (request.AssignedAdminId.HasValue) if (request.AssignedAdminId.HasValue)
proposal.AssignedAdminId = request.AssignedAdminId.Value; proposal.AssignedAdminId = request.AssignedAdminId.Value;
if (request.Status.HasValue && proposal.Status == ProposalStatus.Draft if (request.Status.HasValue && request.Status.Value == ProposalStatus.InReview
&& request.Status.Value == ProposalStatus.InReview) && (proposal.Status == ProposalStatus.Draft || proposal.Status == ProposalStatus.Revised))
proposal.Status = request.Status.Value; proposal.Status = request.Status.Value;
proposal.UpdatedAt = DateTime.UtcNow; proposal.UpdatedAt = DateTime.UtcNow;
@ -256,6 +267,7 @@ public class ProposalService : IProposalService
Priority = proposal.Priority, Priority = proposal.Priority,
Status = ProposalStatus.InReview, Status = ProposalStatus.InReview,
Notes = proposal.Notes, Notes = proposal.Notes,
TotalBidAmount = proposal.TotalBidAmount,
SubmittedById = proposal.SubmittedById, SubmittedById = proposal.SubmittedById,
SubmittedAt = proposal.SubmittedAt, SubmittedAt = proposal.SubmittedAt,
AssignedAdminId = _currentUser.UserId, AssignedAdminId = _currentUser.UserId,

View file

@ -9,10 +9,12 @@ namespace ProposalSystem.Infrastructure.Services;
public class SimilarProposalService : ISimilarProposalService public class SimilarProposalService : ISimilarProposalService
{ {
private readonly ProposalDbContext _db; private readonly ProposalDbContext _db;
private readonly ICurrentUserService _currentUser;
public SimilarProposalService(ProposalDbContext db) public SimilarProposalService(ProposalDbContext db, ICurrentUserService currentUser)
{ {
_db = db; _db = db;
_currentUser = currentUser;
} }
public async Task<IReadOnlyList<SimilarProposalResponse>> GetSimilarProposalsAsync( public async Task<IReadOnlyList<SimilarProposalResponse>> GetSimilarProposalsAsync(
@ -25,15 +27,23 @@ public class SimilarProposalService : ISimilarProposalService
.Take(5) .Take(5)
.ToListAsync(ct); .ToListAsync(ct);
var results = new List<SimilarProposalResponse>(); if (references.Count == 0)
return [];
var libraryItemIds = references.Select(r => r.ReferencedLibraryItemId).ToList();
var proposals = await _db.Proposals
.Include(p => p.LineItems)
.Where(p => libraryItemIds.Contains(p.ProposalNumber))
.ToListAsync(ct);
var proposalsByNumber = proposals.ToDictionary(p => p.ProposalNumber);
var results = new List<SimilarProposalResponse>();
foreach (var reference in references) foreach (var reference in references)
{ {
var referencedProposal = await _db.Proposals if (!proposalsByNumber.TryGetValue(reference.ReferencedLibraryItemId, out var referencedProposal))
.Include(p => p.LineItems) continue;
.FirstOrDefaultAsync(p => p.ProposalNumber == reference.ReferencedLibraryItemId, ct);
if (referencedProposal == null) continue;
results.Add(new SimilarProposalResponse( results.Add(new SimilarProposalResponse(
referencedProposal.ProposalNumber, referencedProposal.ProposalNumber,
@ -69,7 +79,7 @@ public class SimilarProposalService : ISimilarProposalService
ReferencedLibraryItemId = request.ReferencedLibraryItemId, ReferencedLibraryItemId = request.ReferencedLibraryItemId,
SimilarityScore = request.SimilarityScore, SimilarityScore = request.SimilarityScore,
ReferencedAt = DateTime.UtcNow, ReferencedAt = DateTime.UtcNow,
ReferencedById = Guid.Empty, ReferencedById = _currentUser.UserId,
}; };
_db.SimilarProposalReferences.Add(reference); _db.SimilarProposalReferences.Add(reference);

View file

@ -64,7 +64,6 @@ export class ComputeStack extends cdk.Stack {
policy: JSON.stringify([{ policy: JSON.stringify([{
Rules: [ Rules: [
{ ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] }, { ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] },
{ ResourceType: 'dashboard', Resource: ['collection/proposal-system-kb'] },
], ],
AllowFromPublic: true, AllowFromPublic: true,
}]), }]),
@ -263,6 +262,12 @@ export class ComputeStack extends cdk.Stack {
}, },
}); });
const defaultStage = httpApi.defaultStage!.node.defaultChild as apigatewayv2.CfnStage;
defaultStage.defaultRouteSettings = {
throttlingBurstLimit: 50,
throttlingRateLimit: 100,
};
const apiIntegration = new apigatewayv2Integrations.HttpLambdaIntegration( const apiIntegration = new apigatewayv2Integrations.HttpLambdaIntegration(
'ApiIntegration', 'ApiIntegration',
apiFunction apiFunction

View file

@ -125,7 +125,10 @@ export class FoundationStack extends cdk.Stack {
cors: [ cors: [
{ {
allowedMethods: [s3.HttpMethods.PUT, s3.HttpMethods.POST], allowedMethods: [s3.HttpMethods.PUT, s3.HttpMethods.POST],
allowedOrigins: ['*'], allowedOrigins: [
'https://proposals.seahaven.com',
'http://localhost:5173',
],
allowedHeaders: ['*'], allowedHeaders: ['*'],
maxAge: 3600, maxAge: 3600,
}, },

View file

@ -69,12 +69,17 @@ def process_suggestion(proposal_id: str, trigger: str):
suggested_items = generate_line_items(scope, category, priority, similar_proposals) suggested_items = generate_line_items(scope, category, priority, similar_proposals)
if not suggested_items and not existing_items:
logger.warning(
"No suggestions generated and no existing items for %s, skipping status update",
proposal_id,
)
return
post_line_items(proposal_id, suggested_items, existing_items) post_line_items(proposal_id, suggested_items, existing_items)
store_similar_references(proposal_id, similar_proposals) store_similar_references(proposal_id, similar_proposals)
update_status_to_in_review(proposal_id)
def fetch_line_items(proposal_id: str) -> list[dict]: def fetch_line_items(proposal_id: str) -> list[dict]:
try: try:
@ -308,18 +313,6 @@ def store_similar_references(proposal_id: str, similar_proposals: list[dict]):
logger.error("Error storing similar reference: %s", e) logger.error("Error storing similar reference: %s", e)
def update_status_to_in_review(proposal_id: str):
try:
_retry_request(
"PUT",
f"{API_BASE_URL}/api/proposals/{proposal_id}",
json={"status": "InReview"},
headers=_api_headers(),
)
except Exception as e:
logger.error("Error updating status: %s", e)
def _api_headers() -> dict: def _api_headers() -> dict:
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
api_key = _get_api_key() api_key = _get_api_key()

View file

@ -17,3 +17,18 @@ DIST_ID=$(aws cloudformation describe-stacks \
--query "Stacks[0].Outputs[?OutputKey=='DistributionId'].OutputValue" \ --query "Stacks[0].Outputs[?OutputKey=='DistributionId'].OutputValue" \
--output text) --output text)
aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*" aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*"
# Health check: verify API is reachable
API_URL=$(aws cloudformation describe-stacks \
--stack-name proposal-system-compute \
--query "Stacks[0].Outputs[?OutputKey=='ApiEndpoint'].OutputValue" \
--output text)
echo "Running post-deploy health check..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${API_URL}/api/health" --max-time 10 || true)
if [ "$HTTP_STATUS" -eq 200 ]; then
echo "Health check passed (HTTP $HTTP_STATUS)"
else
echo "WARNING: Health check returned HTTP $HTTP_STATUS" >&2
exit 1
fi

View file

@ -14,8 +14,7 @@ import ProposalDetailPage from './pages/proposals/detail/ProposalDetailPage';
import ProposalListPage from './pages/proposals/list/ProposalListPage'; import ProposalListPage from './pages/proposals/list/ProposalListPage';
import AdminDashboard from './pages/admin/dashboard/AdminDashboard'; import AdminDashboard from './pages/admin/dashboard/AdminDashboard';
import AdminWorkspace from './pages/admin/workspace/AdminWorkspace'; import AdminWorkspace from './pages/admin/workspace/AdminWorkspace';
import { DRAWER_WIDTH } from './constants';
const DRAWER_WIDTH = 220;
export default function App() { export default function App() {
const sidebarOpen = useSelector((state: RootState) => selectSidebarOpen(state)); const sidebarOpen = useSelector((state: RootState) => selectSidebarOpen(state));
@ -36,7 +35,7 @@ export default function App() {
component="main" component="main"
sx={{ sx={{
flexGrow: 1, flexGrow: 1,
p: '10px', p: 1.25,
ml: sidebarOpen ? `${DRAWER_WIDTH}px` : 0, ml: sidebarOpen ? `${DRAWER_WIDTH}px` : 0,
transition: 'margin-left 250ms ease', transition: 'margin-left 250ms ease',
minHeight: '100vh', minHeight: '100vh',
@ -50,7 +49,7 @@ export default function App() {
<Route path="/proposals/:id" element={<ProposalDetailPage />} /> <Route path="/proposals/:id" element={<ProposalDetailPage />} />
{/* Admin Routes */} {/* Admin Routes */}
<Route path="/admin" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} /> <Route path="/admin" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard defaultStatus="InReview" /></RoleGuard>} />
<Route path="/admin/proposals" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} /> <Route path="/admin/proposals" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} />
<Route path="/admin/proposals/:id" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminWorkspace /></RoleGuard>} /> <Route path="/admin/proposals/:id" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminWorkspace /></RoleGuard>} />
<Route path="/admin/users" element={<RoleGuard roles={['SysAdmin']}><Typography variant="h5" sx={{ p: 2 }}>User Management — Coming Soon</Typography></RoleGuard>} /> <Route path="/admin/users" element={<RoleGuard roles={['SysAdmin']}><Typography variant="h5" sx={{ p: 2 }}>User Management — Coming Soon</Typography></RoleGuard>} />

View file

@ -1,13 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { STORAGE_KEY_TOKEN } from '../../constants'; import { STORAGE_KEY_TOKEN } from '../../constants';
import type { AuthUser } from '../../lib/api/auth';
interface AuthUser {
id: string;
email: string;
displayName: string;
role: 'Dispatcher' | 'Admin' | 'SysAdmin';
token: string;
}
interface AuthState { interface AuthState {
user: AuthUser | null; user: AuthUser | null;

View file

@ -19,8 +19,7 @@ import PeopleIcon from '@mui/icons-material/People';
import { selectSidebarOpen } from '../app/slices/uiSlice'; import { selectSidebarOpen } from '../app/slices/uiSlice';
import { selectUser } from '../app/slices/authSlice'; import { selectUser } from '../app/slices/authSlice';
import type { RootState } from '../app/store'; import type { RootState } from '../app/store';
import { DRAWER_WIDTH } from '../constants';
const DRAWER_WIDTH = 220;
const dispatcherNav = [ const dispatcherNav = [
{ label: 'Dashboard', path: '/', icon: <DashboardIcon fontSize="small" /> }, { label: 'Dashboard', path: '/', icon: <DashboardIcon fontSize="small" /> },

View file

@ -21,6 +21,7 @@ export default function Topbar() {
edge="start" edge="start"
onClick={() => dispatch(toggleSidebar())} onClick={() => dispatch(toggleSidebar())}
sx={{ mr: 2 }} sx={{ mr: 2 }}
aria-label="Toggle sidebar menu"
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
@ -46,6 +47,7 @@ export default function Topbar() {
<IconButton <IconButton
onClick={logout} onClick={logout}
size="small" size="small"
aria-label="Log out"
sx={{ color: 'rgba(255,255,255,0.5)', '&:hover': { color: '#ff6b6b', bgcolor: 'rgba(255,100,100,0.1)' } }} sx={{ color: 'rgba(255,255,255,0.5)', '&:hover': { color: '#ff6b6b', bgcolor: 'rgba(255,100,100,0.1)' } }}
> >
<LogoutIcon fontSize="small" /> <LogoutIcon fontSize="small" />

View file

@ -135,11 +135,11 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
<TableCell> <TableCell>
{!disabled && ( {!disabled && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}> <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton size="small" onClick={() => moveItem(index, -1)} disabled={index === 0}> <IconButton size="small" onClick={() => moveItem(index, -1)} disabled={index === 0} aria-label={`Move item ${index + 1} up`}>
<ArrowUpwardIcon sx={{ fontSize: 14 }} /> <ArrowUpwardIcon sx={{ fontSize: 14 }} />
</IconButton> </IconButton>
<Typography variant="caption">{index + 1}</Typography> <Typography variant="caption">{index + 1}</Typography>
<IconButton size="small" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1}> <IconButton size="small" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1} aria-label={`Move item ${index + 1} down`}>
<ArrowDownwardIcon sx={{ fontSize: 14 }} /> <ArrowDownwardIcon sx={{ fontSize: 14 }} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -12,6 +12,7 @@ import {
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { adminApi } from '../../lib/api/admin'; import { adminApi } from '../../lib/api/admin';
import { formatCurrency } from '../../lib/format';
import type { EditableLineItem } from './LineItemEditor'; import type { EditableLineItem } from './LineItemEditor';
interface SimilarProposal { interface SimilarProposal {
@ -38,10 +39,7 @@ interface SimilarProposalsPanelProps {
export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) { export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) {
const { data: similar, isLoading } = useQuery<SimilarProposal[]>({ const { data: similar, isLoading } = useQuery<SimilarProposal[]>({
queryKey: ['similarProposals', proposalId], queryKey: ['similarProposals', proposalId],
queryFn: async () => { queryFn: () => adminApi.getSimilar(proposalId) as Promise<SimilarProposal[]>,
const result = await adminApi.getSimilar(proposalId);
return result as SimilarProposal[];
},
enabled: !!proposalId, enabled: !!proposalId,
}); });
@ -104,12 +102,13 @@ export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disa
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" noWrap>{li.description}</Typography> <Typography variant="caption" noWrap>{li.description}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{li.quantity} {li.unit} @ ${li.totalPrice.toFixed(2)} {li.quantity} {li.unit} @ {formatCurrency(li.totalPrice)}
</Typography> </Typography>
</Box> </Box>
{!disabled && ( {!disabled && (
<Button <Button
size="small" size="small"
aria-label={`Copy line item: ${li.description}`}
sx={{ minWidth: 'auto', p: 0.5 }} sx={{ minWidth: 'auto', p: 0.5 }}
onClick={() => onClick={() =>
onPullLineItem({ onPullLineItem({

View file

@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Box, Typography, Skeleton, Chip } from '@mui/material'; import { Box, Typography, Skeleton, Chip } from '@mui/material';
import apiClient from '../../lib/api/client'; import apiClient from '../../lib/api/client';
import { formatCurrency } from '../../lib/format';
interface VendorProposal { interface VendorProposal {
id: string; id: string;
@ -63,7 +64,7 @@ export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
{vp.totalVendorCost > 0 && ( {vp.totalVendorCost > 0 && (
<Typography variant="body2" sx={{ mt: 0.5, fontWeight: 600 }}> <Typography variant="body2" sx={{ mt: 0.5, fontWeight: 600 }}>
Vendor Total: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(vp.totalVendorCost)} Vendor Total: {formatCurrency(vp.totalVendorCost)}
</Typography> </Typography>
)} )}
@ -76,7 +77,7 @@ export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
</Typography> </Typography>
{li.total != null && ( {li.total != null && (
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
${li.total.toFixed(2)} {formatCurrency(li.total)}
</Typography> </Typography>
)} )}
</Box> </Box>

View file

@ -28,6 +28,8 @@ export const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'suc
Revised: 'warning', Revised: 'warning',
}; };
export const DRAWER_WIDTH = 220;
export const PRIORITY_COLORS: Record<string, 'default' | 'warning' | 'error'> = { export const PRIORITY_COLORS: Record<string, 'default' | 'warning' | 'error'> = {
Standard: 'default', Standard: 'default',
Urgent: 'warning', Urgent: 'warning',

View file

@ -0,0 +1,10 @@
import { useEffect } from 'react';
const BASE_TITLE = 'Proposal System';
export function useDocumentTitle(title?: string) {
useEffect(() => {
document.title = title ? `${title} | ${BASE_TITLE}` : BASE_TITLE;
return () => { document.title = BASE_TITLE; };
}, [title]);
}

View file

@ -84,6 +84,8 @@ export const proposalsApi = {
if (filters.status) params.append('status', filters.status); if (filters.status) params.append('status', filters.status);
if (filters.serviceCategory) params.append('serviceCategory', filters.serviceCategory); if (filters.serviceCategory) params.append('serviceCategory', filters.serviceCategory);
if (filters.priority) params.append('priority', filters.priority); if (filters.priority) params.append('priority', filters.priority);
if (filters.fromDate) params.append('fromDate', filters.fromDate);
if (filters.toDate) params.append('toDate', filters.toDate);
if (filters.mine) params.append('mine', 'true'); if (filters.mine) params.append('mine', 'true');
const res = await apiClient.get(`/proposals?${params.toString()}`); const res = await apiClient.get(`/proposals?${params.toString()}`);
@ -95,11 +97,17 @@ export const proposalsApi = {
return res.data; return res.data;
}, },
uploadAttachment: async (proposalId: string, fileName: string): Promise<{ uploadUrl: string; s3Key: string }> => { uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise<{ uploadUrl: string; s3Key: string; vendorProposalId: string }> => {
const res = await apiClient.post(`/proposals/${proposalId}/attachments?fileName=${encodeURIComponent(fileName)}`); const params = new URLSearchParams({ fileName });
if (vendorName) params.append('vendorName', vendorName);
const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`);
return res.data; return res.data;
}, },
confirmUpload: async (proposalId: string, vendorProposalId: string): Promise<void> => {
await apiClient.post(`/proposals/${proposalId}/attachments/${vendorProposalId}/confirm`);
},
getStats: async (): Promise<ProposalStats> => { getStats: async (): Promise<ProposalStats> => {
const res = await apiClient.get('/proposals/stats'); const res = await apiClient.get('/proposals/stats');
return res.data; return res.data;

View file

@ -29,8 +29,9 @@ import { usePaginatedList } from '../../../hooks/usePaginatedList';
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals'; import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
import { adminApi, type DashboardStats } from '../../../lib/api/admin'; import { adminApi, type DashboardStats } from '../../../lib/api/admin';
import { QUERY_KEYS } from '../../../constants/queryKeys'; import { QUERY_KEYS } from '../../../constants/queryKeys';
import { SERVICE_CATEGORIES, PRIORITIES, STATUS_COLORS, PRIORITY_COLORS } from '../../../constants'; import { SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_COLORS, PRIORITY_COLORS } from '../../../constants';
import { formatCurrency, formatDate } from '../../../lib/format'; import { formatCurrency, formatDate } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) { function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) {
return ( return (
@ -48,7 +49,8 @@ function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label:
); );
} }
export default function AdminDashboard() { export default function AdminDashboard({ defaultStatus }: { defaultStatus?: string }) {
useDocumentTitle(defaultStatus ? 'Admin Queue' : 'Admin Dashboard');
const navigate = useNavigate(); const navigate = useNavigate();
const { data: stats, isLoading: statsLoading } = useQuery<DashboardStats>({ const { data: stats, isLoading: statsLoading } = useQuery<DashboardStats>({
@ -56,6 +58,7 @@ export default function AdminDashboard() {
queryFn: adminApi.getDashboard, queryFn: adminApi.getDashboard,
}); });
const [statusFilter, setStatusFilter] = useState(defaultStatus ?? '');
const [categoryFilter, setCategoryFilter] = useState(''); const [categoryFilter, setCategoryFilter] = useState('');
const [priorityFilter, setPriorityFilter] = useState(''); const [priorityFilter, setPriorityFilter] = useState('');
@ -71,6 +74,7 @@ export default function AdminDashboard() {
loading, loading,
err, err,
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, { } = usePaginatedList<ProposalListItem>(proposalsApi.getAll, {
...(statusFilter && { status: statusFilter }),
...(categoryFilter && { serviceCategory: categoryFilter }), ...(categoryFilter && { serviceCategory: categoryFilter }),
...(priorityFilter && { priority: priorityFilter }), ...(priorityFilter && { priority: priorityFilter }),
}); });
@ -175,6 +179,19 @@ export default function AdminDashboard() {
<MenuItem key={p} value={p}>{p}</MenuItem> <MenuItem key={p} value={p}>{p}</MenuItem>
))} ))}
</TextField> </TextField>
<TextField
size="small"
select
label="Status"
value={statusFilter}
sx={{ width: 140 }}
onChange={(e) => setStatusFilter(e.target.value)}
>
<MenuItem value="">All</MenuItem>
{PROPOSAL_STATUSES.map((s) => (
<MenuItem key={s} value={s}>{s}</MenuItem>
))}
</TextField>
</Box> </Box>
{err && ( {err && (

View file

@ -43,6 +43,8 @@ export default function AdminWorkspace() {
const [lineItems, setLineItems] = useState<EditableLineItem[]>([]); const [lineItems, setLineItems] = useState<EditableLineItem[]>([]);
const [refinedScope, setRefinedScope] = useState(''); const [refinedScope, setRefinedScope] = useState('');
const [approveDialogOpen, setApproveDialogOpen] = useState(false); const [approveDialogOpen, setApproveDialogOpen] = useState(false);
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [reviseDialogOpen, setReviseDialogOpen] = useState(false);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const { data: proposal, isLoading } = useQuery<ProposalDetail>({ const { data: proposal, isLoading } = useQuery<ProposalDetail>({
@ -144,6 +146,7 @@ export default function AdminWorkspace() {
mutationFn: () => adminApi.sendProposal(id!), mutationFn: () => adminApi.sendProposal(id!),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
setSendDialogOpen(false);
toast.success('Proposal marked as sent'); toast.success('Proposal marked as sent');
}, },
}); });
@ -152,6 +155,7 @@ export default function AdminWorkspace() {
mutationFn: () => adminApi.reviseProposal(id!), mutationFn: () => adminApi.reviseProposal(id!),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
setReviseDialogOpen(false);
toast.success('Revision created'); toast.success('Revision created');
}, },
}); });
@ -383,7 +387,7 @@ export default function AdminWorkspace() {
variant="contained" variant="contained"
color="success" color="success"
startIcon={<SendIcon />} startIcon={<SendIcon />}
onClick={() => sendMutation.mutate()} onClick={() => setSendDialogOpen(true)}
disabled={sendMutation.isPending} disabled={sendMutation.isPending}
> >
Mark as Sent Mark as Sent
@ -395,7 +399,7 @@ export default function AdminWorkspace() {
<Button <Button
variant="outlined" variant="outlined"
startIcon={<HistoryIcon />} startIcon={<HistoryIcon />}
onClick={() => reviseMutation.mutate()} onClick={() => setReviseDialogOpen(true)}
disabled={reviseMutation.isPending} disabled={reviseMutation.isPending}
> >
Create Revision Create Revision
@ -449,6 +453,49 @@ export default function AdminWorkspace() {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Mark as Sent Confirmation Dialog */}
<Dialog open={sendDialogOpen} onClose={() => setSendDialogOpen(false)}>
<DialogTitle>Mark as Sent</DialogTitle>
<DialogContent>
<Typography>
Mark <strong>{proposal.proposalNumber}</strong> as sent to {proposal.customerName}?
This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setSendDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
color="success"
onClick={() => sendMutation.mutate()}
disabled={sendMutation.isPending}
>
{sendMutation.isPending ? 'Sending...' : 'Confirm Send'}
</Button>
</DialogActions>
</Dialog>
{/* Create Revision Confirmation Dialog */}
<Dialog open={reviseDialogOpen} onClose={() => setReviseDialogOpen(false)}>
<DialogTitle>Create Revision</DialogTitle>
<DialogContent>
<Typography>
Create a new revision of <strong>{proposal.proposalNumber}</strong>?
The current version will be marked as revised and a new editable copy will be created.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setReviseDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
onClick={() => reviseMutation.mutate()}
disabled={reviseMutation.isPending}
>
{reviseMutation.isPending ? 'Creating...' : 'Confirm Revision'}
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }

View file

@ -25,6 +25,7 @@ import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../l
import { QUERY_KEYS } from '../../constants/queryKeys'; import { QUERY_KEYS } from '../../constants/queryKeys';
import { STATUS_COLORS } from '../../constants'; import { STATUS_COLORS } from '../../constants';
import { formatCurrency, formatDate } from '../../lib/format'; import { formatCurrency, formatDate } from '../../lib/format';
import { useDocumentTitle } from '../../hooks/useDocumentTitle';
function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return ( return (
@ -43,9 +44,10 @@ function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string;
} }
export default function Dashboard() { export default function Dashboard() {
useDocumentTitle('Dashboard');
const navigate = useNavigate(); const navigate = useNavigate();
const { data: recentData, isLoading: recentLoading } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({ const { data: recentData, isLoading: recentLoading, error: recentError } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({
queryKey: [QUERY_KEYS.proposals, 'dashboard-recent'], queryKey: [QUERY_KEYS.proposals, 'dashboard-recent'],
queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: true }), queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: true }),
}); });
@ -155,7 +157,16 @@ export default function Dashboard() {
<TableCell>{formatDate(row.submittedAt)}</TableCell> <TableCell>{formatDate(row.submittedAt)}</TableCell>
</TableRow> </TableRow>
))} ))}
{!isLoading && proposals.length === 0 && ( {!isLoading && recentError && (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<Typography color="error">
Failed to load proposals. Please try again.
</Typography>
</TableCell>
</TableRow>
)}
{!isLoading && !recentError && proposals.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}> <TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary"> <Typography color="text.secondary">

View file

@ -18,8 +18,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
import { STATUS_COLORS } from '../../../constants'; import { STATUS_COLORS } from '../../../constants';
import { formatCurrency, formatDateTime } from '../../../lib/format'; import { formatCurrency, formatDateTime } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
const STATUS_ORDER = ['Draft', 'InReview', 'Approved', 'Sent', 'Revised']; const STATUS_ORDER = ['Draft', 'InReview', 'Approved', 'Sent'];
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return ( return (
@ -42,6 +43,8 @@ export default function ProposalDetailPage() {
enabled: !!id, enabled: !!id,
}); });
useDocumentTitle(proposal?.proposalNumber ?? 'Proposal');
if (isLoading) { if (isLoading) {
return ( return (
<Box> <Box>
@ -87,13 +90,19 @@ export default function ProposalDetailPage() {
<Typography variant="subtitle2" sx={{ mb: 2 }}> <Typography variant="subtitle2" sx={{ mb: 2 }}>
Status Timeline Status Timeline
</Typography> </Typography>
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel> {proposal.status === 'Revised' ? (
{STATUS_ORDER.map((label) => ( <Box sx={{ textAlign: 'center', py: 1 }}>
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}> <Chip label="Revised — a new revision has been created" color="warning" />
<StepLabel>{label}</StepLabel> </Box>
</Step> ) : (
))} <Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
</Stepper> {STATUS_ORDER.map((label) => (
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
)}
</CardContent> </CardContent>
</Card> </Card>

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { import {
@ -39,6 +39,12 @@ export default function ProposalFormPage() {
const [vendorFile, setVendorFile] = useState<File | null>(null); const [vendorFile, setVendorFile] = useState<File | null>(null);
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
};
}, []);
const handleChange = (field: keyof CreateProposalRequest, value: string) => { const handleChange = (field: keyof CreateProposalRequest, value: string) => {
setForm((prev) => ({ ...prev, [field]: value })); setForm((prev) => ({ ...prev, [field]: value }));
}; };
@ -76,12 +82,17 @@ export default function ProposalFormPage() {
const proposal = await proposalsApi.create(form); const proposal = await proposalsApi.create(form);
if (vendorFile) { if (vendorFile) {
const { uploadUrl } = await proposalsApi.uploadAttachment(proposal.id, vendorFile.name); try {
await fetch(uploadUrl, { const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment(proposal.id, vendorFile.name);
method: 'PUT', await fetch(uploadUrl, {
body: vendorFile, method: 'PUT',
headers: { 'Content-Type': vendorFile.type || 'application/pdf' }, body: vendorFile,
}); headers: { 'Content-Type': vendorFile.type || 'application/pdf' },
});
await proposalsApi.confirmUpload(proposal.id, vendorProposalId);
} catch {
toast.warning(`Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.`);
}
} }
return proposal; return proposal;

View file

@ -23,8 +23,10 @@ import { usePaginatedList } from '../../../hooks/usePaginatedList';
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals'; import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
import { STATUS_COLORS } from '../../../constants'; import { STATUS_COLORS } from '../../../constants';
import { formatCurrency, formatDate } from '../../../lib/format'; import { formatCurrency, formatDate } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
export default function ProposalListPage() { export default function ProposalListPage() {
useDocumentTitle('My Proposals');
const navigate = useNavigate(); const navigate = useNavigate();
const mineParams = { mine: true }; const mineParams = { mine: true };
const { const {