mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 06:33:13 +00:00
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
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:
parent
a199b4675c
commit
9d856a9619
29 changed files with 289 additions and 93 deletions
10
.github/workflows/ci.yaml
vendored
10
.github/workflows/ci.yaml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
.CountAsync(p => p.ApprovedAt.HasValue, ct);
|
||||||
|
|
||||||
|
double avgTurnaround = 0;
|
||||||
|
if (approvedCount > 0)
|
||||||
|
{
|
||||||
|
var recentApproved = await _db.Proposals
|
||||||
.Where(p => p.ApprovedAt.HasValue)
|
.Where(p => p.ApprovedAt.HasValue)
|
||||||
|
.OrderByDescending(p => p.ApprovedAt)
|
||||||
|
.Take(200)
|
||||||
.Select(p => new { p.ApprovedAt, p.SubmittedAt })
|
.Select(p => new { p.ApprovedAt, p.SubmittedAt })
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
avgTurnaround = recentApproved.Average(p => (p.ApprovedAt!.Value - p.SubmittedAt).TotalHours);
|
||||||
var avgTurnaround = approvedTimes.Count > 0
|
}
|
||||||
? approvedTimes.Average(p => (p.ApprovedAt!.Value - p.SubmittedAt).TotalHours)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
var totalProposals = await _db.Proposals.CountAsync(ct);
|
var totalProposals = await _db.Proposals.CountAsync(ct);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,17 @@ public class ProposalService : IProposalService
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct);
|
await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct);
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>} />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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" /> },
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
10
web/src/hooks/useDocumentTitle.ts
Normal file
10
web/src/hooks/useDocumentTitle.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,6 +90,11 @@ export default function ProposalDetailPage() {
|
||||||
<Typography variant="subtitle2" sx={{ mb: 2 }}>
|
<Typography variant="subtitle2" sx={{ mb: 2 }}>
|
||||||
Status Timeline
|
Status Timeline
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{proposal.status === 'Revised' ? (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 1 }}>
|
||||||
|
<Chip label="Revised — a new revision has been created" color="warning" />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
|
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
|
||||||
{STATUS_ORDER.map((label) => (
|
{STATUS_ORDER.map((label) => (
|
||||||
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
|
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
|
||||||
|
|
@ -94,6 +102,7 @@ export default function ProposalDetailPage() {
|
||||||
</Step>
|
</Step>
|
||||||
))}
|
))}
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment(proposal.id, vendorFile.name);
|
||||||
await fetch(uploadUrl, {
|
await fetch(uploadUrl, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: vendorFile,
|
body: vendorFile,
|
||||||
headers: { 'Content-Type': vendorFile.type || 'application/pdf' },
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue