From 9d856a9619962ef6e3b5df4bceed596d3eed2e6f Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 20 May 2026 19:38:36 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20audit=20fixes:=20FIX-01=E2=80=9347,?= =?UTF-8?q?=20accessibility=20NITs,=20code=20quality=20NITs=20[skip=20depl?= =?UTF-8?q?oy]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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] --- .github/workflows/ci.yaml | 10 ++++ .../Controllers/AdminController.cs | 20 +++++--- .../Controllers/FilesController.cs | 27 +++++++++- .../Middleware/GlobalExceptionHandler.cs | 4 +- .../Middleware/InternalApiKeyMiddleware.cs | 9 +++- api/src/ProposalSystem.Api/Program.cs | 7 +-- .../DTOs/FileDtos.cs | 3 +- .../Services/ProposalService.cs | 26 +++++++--- .../Services/SimilarProposalService.cs | 26 +++++++--- infra/lib/compute-stack.ts | 7 ++- infra/lib/foundation-stack.ts | 5 +- lambdas/suggestions/app.py | 21 +++----- scripts/post-deploy.sh | 15 ++++++ web/src/App.tsx | 7 ++- web/src/app/slices/authSlice.ts | 9 +--- web/src/components/Sidebar.tsx | 3 +- web/src/components/Topbar.tsx | 2 + web/src/components/admin/LineItemEditor.tsx | 4 +- .../admin/SimilarProposalsPanel.tsx | 9 ++-- web/src/components/admin/VendorDataPanel.tsx | 5 +- web/src/constants/index.ts | 2 + web/src/hooks/useDocumentTitle.ts | 10 ++++ web/src/lib/api/proposals.ts | 12 ++++- .../pages/admin/dashboard/AdminDashboard.tsx | 21 +++++++- .../pages/admin/workspace/AdminWorkspace.tsx | 51 ++++++++++++++++++- web/src/pages/dashboard/Dashboard.tsx | 15 +++++- .../proposals/detail/ProposalDetailPage.tsx | 25 ++++++--- .../pages/proposals/form/ProposalFormPage.tsx | 25 ++++++--- .../pages/proposals/list/ProposalListPage.tsx | 2 + 29 files changed, 289 insertions(+), 93 deletions(-) create mode 100644 web/src/hooks/useDocumentTitle.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e3c952c..71d3272 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3,6 +3,16 @@ name: CI on: pull_request: branches: [main] + paths-ignore: + - '*.md' + - 'docs/**' + - 'AUDIT-*.md' + - '.claude/**' + - 'LICENSE' + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true permissions: contents: read diff --git a/api/src/ProposalSystem.Api/Controllers/AdminController.cs b/api/src/ProposalSystem.Api/Controllers/AdminController.cs index d5bd61c..2b226fc 100644 --- a/api/src/ProposalSystem.Api/Controllers/AdminController.cs +++ b/api/src/ProposalSystem.Api/Controllers/AdminController.cs @@ -29,14 +29,20 @@ public class AdminController : ControllerBase var approvedThisWeek = await _db.Proposals .CountAsync(p => p.ApprovedAt >= weekStart, ct); - var approvedTimes = await _db.Proposals - .Where(p => p.ApprovedAt.HasValue) - .Select(p => new { p.ApprovedAt, p.SubmittedAt }) - .ToListAsync(ct); + var approvedCount = await _db.Proposals + .CountAsync(p => p.ApprovedAt.HasValue, ct); - var avgTurnaround = approvedTimes.Count > 0 - ? approvedTimes.Average(p => (p.ApprovedAt!.Value - p.SubmittedAt).TotalHours) - : 0; + double avgTurnaround = 0; + if (approvedCount > 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); diff --git a/api/src/ProposalSystem.Api/Controllers/FilesController.cs b/api/src/ProposalSystem.Api/Controllers/FilesController.cs index 8026dd0..fdbf3a9 100644 --- a/api/src/ProposalSystem.Api/Controllers/FilesController.cs +++ b/api/src/ProposalSystem.Api/Controllers/FilesController.cs @@ -38,6 +38,7 @@ public class FilesController : ControllerBase public async Task> UploadAttachment( Guid proposalId, [FromQuery] string fileName, + [FromQuery] string? vendorName, CancellationToken ct) { var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct); @@ -56,6 +57,7 @@ public class FilesController : ControllerBase { Id = Guid.NewGuid(), ProposalId = proposalId, + VendorName = vendorName ?? string.Empty, FileName = fileName, S3Key = s3Key, UploadedAt = DateTime.UtcNow, @@ -65,9 +67,30 @@ public class FilesController : ControllerBase _db.VendorProposals.Add(vendorProposal); 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 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")] diff --git a/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs b/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs index fb848ad..322b66a 100644 --- a/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs +++ b/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs @@ -58,10 +58,10 @@ public class GlobalExceptionHandler : IMiddleware } ), InvalidOperationException => ( - HttpStatusCode.Conflict, + HttpStatusCode.BadRequest, new ProblemDetails { - Status = 409, + Status = 400, Title = "Invalid Operation", Detail = exception.Message, } diff --git a/api/src/ProposalSystem.Api/Middleware/InternalApiKeyMiddleware.cs b/api/src/ProposalSystem.Api/Middleware/InternalApiKeyMiddleware.cs index bd2d747..86ce1a5 100644 --- a/api/src/ProposalSystem.Api/Middleware/InternalApiKeyMiddleware.cs +++ b/api/src/ProposalSystem.Api/Middleware/InternalApiKeyMiddleware.cs @@ -8,10 +8,12 @@ public class InternalApiKeyMiddleware { private readonly RequestDelegate _next; private readonly byte[] _apiKeyBytes; + private readonly ILogger _logger; - public InternalApiKeyMiddleware(RequestDelegate next, IConfiguration configuration) + public InternalApiKeyMiddleware(RequestDelegate next, IConfiguration configuration, ILogger logger) { _next = next; + _logger = logger; var key = configuration["INTERNAL_API_KEY"] ?? ""; _apiKeyBytes = Encoding.UTF8.GetBytes(key); } @@ -38,6 +40,11 @@ public class InternalApiKeyMiddleware var identity = new ClaimsIdentity(claims, "InternalApiKey"); 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); diff --git a/api/src/ProposalSystem.Api/Program.cs b/api/src/ProposalSystem.Api/Program.cs index 2024dda..c14452f 100644 --- a/api/src/ProposalSystem.Api/Program.cs +++ b/api/src/ProposalSystem.Api/Program.cs @@ -157,9 +157,10 @@ builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => { - policy.WithOrigins( - "https://proposals.seahaven.com", - "http://localhost:5173") + var origins = new List { "https://proposals.seahaven.com" }; + if (builder.Environment.IsDevelopment()) + origins.Add("http://localhost:5173"); + policy.WithOrigins(origins.ToArray()) .AllowAnyMethod() .AllowAnyHeader(); }); diff --git a/api/src/ProposalSystem.Application/DTOs/FileDtos.cs b/api/src/ProposalSystem.Application/DTOs/FileDtos.cs index ea7e393..c5acfb0 100644 --- a/api/src/ProposalSystem.Application/DTOs/FileDtos.cs +++ b/api/src/ProposalSystem.Application/DTOs/FileDtos.cs @@ -3,7 +3,8 @@ namespace ProposalSystem.Application.DTOs; public record PresignedUploadResponse( string UploadUrl, string S3Key, - DateTime ExpiresAt + DateTime ExpiresAt, + Guid VendorProposalId ); public record PdfDownloadResponse( diff --git a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs index e41273c..aa684cc 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs @@ -57,9 +57,17 @@ public class ProposalService : IProposalService await _db.SaveChangesAsync(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); } @@ -80,6 +88,9 @@ public class ProposalService : IProposalService public async Task> 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 .Include(p => p.SubmittedBy) .Include(p => p.AssignedAdmin) @@ -119,8 +130,8 @@ public class ProposalService : IProposalService var items = await query .OrderByDescending(p => p.Priority) .ThenByDescending(p => p.SubmittedAt) - .Skip((filter.Page - 1) * filter.PageSize) - .Take(filter.PageSize) + .Skip((page - 1) * pageSize) + .Take(pageSize) .Select(p => new ProposalListResponse( p.Id, p.ProposalNumber, @@ -136,7 +147,7 @@ public class ProposalService : IProposalService )) .ToListAsync(ct); - return new PagedResponse(items, totalCount, filter.Page, filter.PageSize); + return new PagedResponse(items, totalCount, page, pageSize); } public async Task UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default) @@ -157,8 +168,8 @@ public class ProposalService : IProposalService if (request.AssignedAdminId.HasValue) proposal.AssignedAdminId = request.AssignedAdminId.Value; - if (request.Status.HasValue && proposal.Status == ProposalStatus.Draft - && request.Status.Value == ProposalStatus.InReview) + if (request.Status.HasValue && request.Status.Value == ProposalStatus.InReview + && (proposal.Status == ProposalStatus.Draft || proposal.Status == ProposalStatus.Revised)) proposal.Status = request.Status.Value; proposal.UpdatedAt = DateTime.UtcNow; @@ -256,6 +267,7 @@ public class ProposalService : IProposalService Priority = proposal.Priority, Status = ProposalStatus.InReview, Notes = proposal.Notes, + TotalBidAmount = proposal.TotalBidAmount, SubmittedById = proposal.SubmittedById, SubmittedAt = proposal.SubmittedAt, AssignedAdminId = _currentUser.UserId, diff --git a/api/src/ProposalSystem.Infrastructure/Services/SimilarProposalService.cs b/api/src/ProposalSystem.Infrastructure/Services/SimilarProposalService.cs index 71b8a9e..620fbf8 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/SimilarProposalService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/SimilarProposalService.cs @@ -9,10 +9,12 @@ namespace ProposalSystem.Infrastructure.Services; public class SimilarProposalService : ISimilarProposalService { private readonly ProposalDbContext _db; + private readonly ICurrentUserService _currentUser; - public SimilarProposalService(ProposalDbContext db) + public SimilarProposalService(ProposalDbContext db, ICurrentUserService currentUser) { _db = db; + _currentUser = currentUser; } public async Task> GetSimilarProposalsAsync( @@ -25,15 +27,23 @@ public class SimilarProposalService : ISimilarProposalService .Take(5) .ToListAsync(ct); - var results = new List(); + 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(); foreach (var reference in references) { - var referencedProposal = await _db.Proposals - .Include(p => p.LineItems) - .FirstOrDefaultAsync(p => p.ProposalNumber == reference.ReferencedLibraryItemId, ct); - - if (referencedProposal == null) continue; + if (!proposalsByNumber.TryGetValue(reference.ReferencedLibraryItemId, out var referencedProposal)) + continue; results.Add(new SimilarProposalResponse( referencedProposal.ProposalNumber, @@ -69,7 +79,7 @@ public class SimilarProposalService : ISimilarProposalService ReferencedLibraryItemId = request.ReferencedLibraryItemId, SimilarityScore = request.SimilarityScore, ReferencedAt = DateTime.UtcNow, - ReferencedById = Guid.Empty, + ReferencedById = _currentUser.UserId, }; _db.SimilarProposalReferences.Add(reference); diff --git a/infra/lib/compute-stack.ts b/infra/lib/compute-stack.ts index f6a808b..301817a 100644 --- a/infra/lib/compute-stack.ts +++ b/infra/lib/compute-stack.ts @@ -64,7 +64,6 @@ export class ComputeStack extends cdk.Stack { policy: JSON.stringify([{ Rules: [ { ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] }, - { ResourceType: 'dashboard', Resource: ['collection/proposal-system-kb'] }, ], 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( 'ApiIntegration', apiFunction diff --git a/infra/lib/foundation-stack.ts b/infra/lib/foundation-stack.ts index 62ee25a..1a28fc8 100644 --- a/infra/lib/foundation-stack.ts +++ b/infra/lib/foundation-stack.ts @@ -125,7 +125,10 @@ export class FoundationStack extends cdk.Stack { cors: [ { allowedMethods: [s3.HttpMethods.PUT, s3.HttpMethods.POST], - allowedOrigins: ['*'], + allowedOrigins: [ + 'https://proposals.seahaven.com', + 'http://localhost:5173', + ], allowedHeaders: ['*'], maxAge: 3600, }, diff --git a/lambdas/suggestions/app.py b/lambdas/suggestions/app.py index e301110..17cabf9 100644 --- a/lambdas/suggestions/app.py +++ b/lambdas/suggestions/app.py @@ -69,12 +69,17 @@ def process_suggestion(proposal_id: str, trigger: str): 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) store_similar_references(proposal_id, similar_proposals) - update_status_to_in_review(proposal_id) - def fetch_line_items(proposal_id: str) -> list[dict]: try: @@ -308,18 +313,6 @@ def store_similar_references(proposal_id: str, similar_proposals: list[dict]): 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: headers = {"Content-Type": "application/json"} api_key = _get_api_key() diff --git a/scripts/post-deploy.sh b/scripts/post-deploy.sh index 42dc379..ef0cbc6 100755 --- a/scripts/post-deploy.sh +++ b/scripts/post-deploy.sh @@ -17,3 +17,18 @@ DIST_ID=$(aws cloudformation describe-stacks \ --query "Stacks[0].Outputs[?OutputKey=='DistributionId'].OutputValue" \ --output text) 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 diff --git a/web/src/App.tsx b/web/src/App.tsx index ca5169a..66eb219 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,8 +14,7 @@ import ProposalDetailPage from './pages/proposals/detail/ProposalDetailPage'; import ProposalListPage from './pages/proposals/list/ProposalListPage'; import AdminDashboard from './pages/admin/dashboard/AdminDashboard'; import AdminWorkspace from './pages/admin/workspace/AdminWorkspace'; - -const DRAWER_WIDTH = 220; +import { DRAWER_WIDTH } from './constants'; export default function App() { const sidebarOpen = useSelector((state: RootState) => selectSidebarOpen(state)); @@ -36,7 +35,7 @@ export default function App() { component="main" sx={{ flexGrow: 1, - p: '10px', + p: 1.25, ml: sidebarOpen ? `${DRAWER_WIDTH}px` : 0, transition: 'margin-left 250ms ease', minHeight: '100vh', @@ -50,7 +49,7 @@ export default function App() { } /> {/* Admin Routes */} - } /> + } /> } /> } /> User Management — Coming Soon} /> diff --git a/web/src/app/slices/authSlice.ts b/web/src/app/slices/authSlice.ts index 2b03f92..2de12bc 100644 --- a/web/src/app/slices/authSlice.ts +++ b/web/src/app/slices/authSlice.ts @@ -1,13 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { STORAGE_KEY_TOKEN } from '../../constants'; - -interface AuthUser { - id: string; - email: string; - displayName: string; - role: 'Dispatcher' | 'Admin' | 'SysAdmin'; - token: string; -} +import type { AuthUser } from '../../lib/api/auth'; interface AuthState { user: AuthUser | null; diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index e42d026..d0e0ef7 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -19,8 +19,7 @@ import PeopleIcon from '@mui/icons-material/People'; import { selectSidebarOpen } from '../app/slices/uiSlice'; import { selectUser } from '../app/slices/authSlice'; import type { RootState } from '../app/store'; - -const DRAWER_WIDTH = 220; +import { DRAWER_WIDTH } from '../constants'; const dispatcherNav = [ { label: 'Dashboard', path: '/', icon: }, diff --git a/web/src/components/Topbar.tsx b/web/src/components/Topbar.tsx index e384227..d0c4e47 100644 --- a/web/src/components/Topbar.tsx +++ b/web/src/components/Topbar.tsx @@ -21,6 +21,7 @@ export default function Topbar() { edge="start" onClick={() => dispatch(toggleSidebar())} sx={{ mr: 2 }} + aria-label="Toggle sidebar menu" > @@ -46,6 +47,7 @@ export default function Topbar() { diff --git a/web/src/components/admin/LineItemEditor.tsx b/web/src/components/admin/LineItemEditor.tsx index cccc855..53535e6 100644 --- a/web/src/components/admin/LineItemEditor.tsx +++ b/web/src/components/admin/LineItemEditor.tsx @@ -135,11 +135,11 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li {!disabled && ( - moveItem(index, -1)} disabled={index === 0}> + moveItem(index, -1)} disabled={index === 0} aria-label={`Move item ${index + 1} up`}> {index + 1} - moveItem(index, 1)} disabled={index === items.length - 1}> + moveItem(index, 1)} disabled={index === items.length - 1} aria-label={`Move item ${index + 1} down`}> diff --git a/web/src/components/admin/SimilarProposalsPanel.tsx b/web/src/components/admin/SimilarProposalsPanel.tsx index 07661e7..fdc4d6c 100644 --- a/web/src/components/admin/SimilarProposalsPanel.tsx +++ b/web/src/components/admin/SimilarProposalsPanel.tsx @@ -12,6 +12,7 @@ import { import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import { adminApi } from '../../lib/api/admin'; +import { formatCurrency } from '../../lib/format'; import type { EditableLineItem } from './LineItemEditor'; interface SimilarProposal { @@ -38,10 +39,7 @@ interface SimilarProposalsPanelProps { export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) { const { data: similar, isLoading } = useQuery({ queryKey: ['similarProposals', proposalId], - queryFn: async () => { - const result = await adminApi.getSimilar(proposalId); - return result as SimilarProposal[]; - }, + queryFn: () => adminApi.getSimilar(proposalId) as Promise, enabled: !!proposalId, }); @@ -104,12 +102,13 @@ export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disa {li.description} - {li.quantity} {li.unit} @ ${li.totalPrice.toFixed(2)} + {li.quantity} {li.unit} @ {formatCurrency(li.totalPrice)} {!disabled && ( + + {/* Mark as Sent Confirmation Dialog */} + setSendDialogOpen(false)}> + Mark as Sent + + + Mark {proposal.proposalNumber} as sent to {proposal.customerName}? + This action cannot be undone. + + + + + + + + + {/* Create Revision Confirmation Dialog */} + setReviseDialogOpen(false)}> + Create Revision + + + Create a new revision of {proposal.proposalNumber}? + The current version will be marked as revised and a new editable copy will be created. + + + + + + + ); } diff --git a/web/src/pages/dashboard/Dashboard.tsx b/web/src/pages/dashboard/Dashboard.tsx index 10be34c..235665c 100644 --- a/web/src/pages/dashboard/Dashboard.tsx +++ b/web/src/pages/dashboard/Dashboard.tsx @@ -25,6 +25,7 @@ import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../l import { QUERY_KEYS } from '../../constants/queryKeys'; import { STATUS_COLORS } from '../../constants'; import { formatCurrency, formatDate } from '../../lib/format'; +import { useDocumentTitle } from '../../hooks/useDocumentTitle'; function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { return ( @@ -43,9 +44,10 @@ function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; } export default function Dashboard() { + useDocumentTitle('Dashboard'); 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'], queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: true }), }); @@ -155,7 +157,16 @@ export default function Dashboard() { {formatDate(row.submittedAt)} ))} - {!isLoading && proposals.length === 0 && ( + {!isLoading && recentError && ( + + + + Failed to load proposals. Please try again. + + + + )} + {!isLoading && !recentError && proposals.length === 0 && ( diff --git a/web/src/pages/proposals/detail/ProposalDetailPage.tsx b/web/src/pages/proposals/detail/ProposalDetailPage.tsx index 4c87695..2d580f2 100644 --- a/web/src/pages/proposals/detail/ProposalDetailPage.tsx +++ b/web/src/pages/proposals/detail/ProposalDetailPage.tsx @@ -18,8 +18,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; import { STATUS_COLORS } from '../../../constants'; 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 }) { return ( @@ -42,6 +43,8 @@ export default function ProposalDetailPage() { enabled: !!id, }); + useDocumentTitle(proposal?.proposalNumber ?? 'Proposal'); + if (isLoading) { return ( @@ -87,13 +90,19 @@ export default function ProposalDetailPage() { Status Timeline - = 0 ? activeStep : 0} alternativeLabel> - {STATUS_ORDER.map((label) => ( - - {label} - - ))} - + {proposal.status === 'Revised' ? ( + + + + ) : ( + = 0 ? activeStep : 0} alternativeLabel> + {STATUS_ORDER.map((label) => ( + + {label} + + ))} + + )} diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx index 8337342..db59f0f 100644 --- a/web/src/pages/proposals/form/ProposalFormPage.tsx +++ b/web/src/pages/proposals/form/ProposalFormPage.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useMutation } from '@tanstack/react-query'; import { @@ -39,6 +39,12 @@ export default function ProposalFormPage() { const [vendorFile, setVendorFile] = useState(null); const searchDebounceRef = useRef | null>(null); + useEffect(() => { + return () => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + }; + }, []); + const handleChange = (field: keyof CreateProposalRequest, value: string) => { setForm((prev) => ({ ...prev, [field]: value })); }; @@ -76,12 +82,17 @@ export default function ProposalFormPage() { const proposal = await proposalsApi.create(form); if (vendorFile) { - const { uploadUrl } = await proposalsApi.uploadAttachment(proposal.id, vendorFile.name); - await fetch(uploadUrl, { - method: 'PUT', - body: vendorFile, - headers: { 'Content-Type': vendorFile.type || 'application/pdf' }, - }); + try { + const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment(proposal.id, vendorFile.name); + await fetch(uploadUrl, { + method: 'PUT', + 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; diff --git a/web/src/pages/proposals/list/ProposalListPage.tsx b/web/src/pages/proposals/list/ProposalListPage.tsx index 63c2c68..d11f5b4 100644 --- a/web/src/pages/proposals/list/ProposalListPage.tsx +++ b/web/src/pages/proposals/list/ProposalListPage.tsx @@ -23,8 +23,10 @@ import { usePaginatedList } from '../../../hooks/usePaginatedList'; import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals'; import { STATUS_COLORS } from '../../../constants'; import { formatCurrency, formatDate } from '../../../lib/format'; +import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; export default function ProposalListPage() { + useDocumentTitle('My Proposals'); const navigate = useNavigate(); const mineParams = { mine: true }; const {