mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 03:03: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:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '*.md'
|
||||
- 'docs/**'
|
||||
- 'AUDIT-*.md'
|
||||
- '.claude/**'
|
||||
- 'LICENSE'
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ public class FilesController : ControllerBase
|
|||
public async Task<ActionResult<PresignedUploadResponse>> 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<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")]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ public class InternalApiKeyMiddleware
|
|||
{
|
||||
private readonly RequestDelegate _next;
|
||||
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;
|
||||
_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);
|
||||
|
|
|
|||
|
|
@ -157,9 +157,10 @@ builder.Services.AddCors(options =>
|
|||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.WithOrigins(
|
||||
"https://proposals.seahaven.com",
|
||||
"http://localhost:5173")
|
||||
var origins = new List<string> { "https://proposals.seahaven.com" };
|
||||
if (builder.Environment.IsDevelopment())
|
||||
origins.Add("http://localhost:5173");
|
||||
policy.WithOrigins(origins.ToArray())
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ namespace ProposalSystem.Application.DTOs;
|
|||
public record PresignedUploadResponse(
|
||||
string UploadUrl,
|
||||
string S3Key,
|
||||
DateTime ExpiresAt
|
||||
DateTime ExpiresAt,
|
||||
Guid VendorProposalId
|
||||
);
|
||||
|
||||
public record PdfDownloadResponse(
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
.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<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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<IReadOnlyList<SimilarProposalResponse>> GetSimilarProposalsAsync(
|
||||
|
|
@ -25,15 +27,23 @@ public class SimilarProposalService : ISimilarProposalService
|
|||
.Take(5)
|
||||
.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)
|
||||
{
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
||||
|
||||
{/* 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/: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>} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: <DashboardIcon fontSize="small" /> },
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export default function Topbar() {
|
|||
edge="start"
|
||||
onClick={() => dispatch(toggleSidebar())}
|
||||
sx={{ mr: 2 }}
|
||||
aria-label="Toggle sidebar menu"
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
|
@ -46,6 +47,7 @@ export default function Topbar() {
|
|||
<IconButton
|
||||
onClick={logout}
|
||||
size="small"
|
||||
aria-label="Log out"
|
||||
sx={{ color: 'rgba(255,255,255,0.5)', '&:hover': { color: '#ff6b6b', bgcolor: 'rgba(255,100,100,0.1)' } }}
|
||||
>
|
||||
<LogoutIcon fontSize="small" />
|
||||
|
|
|
|||
|
|
@ -135,11 +135,11 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
|
|||
<TableCell>
|
||||
{!disabled && (
|
||||
<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 }} />
|
||||
</IconButton>
|
||||
<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 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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<SimilarProposal[]>({
|
||||
queryKey: ['similarProposals', proposalId],
|
||||
queryFn: async () => {
|
||||
const result = await adminApi.getSimilar(proposalId);
|
||||
return result as SimilarProposal[];
|
||||
},
|
||||
queryFn: () => adminApi.getSimilar(proposalId) as Promise<SimilarProposal[]>,
|
||||
enabled: !!proposalId,
|
||||
});
|
||||
|
||||
|
|
@ -104,12 +102,13 @@ export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disa
|
|||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" noWrap>{li.description}</Typography>
|
||||
<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>
|
||||
</Box>
|
||||
{!disabled && (
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={`Copy line item: ${li.description}`}
|
||||
sx={{ minWidth: 'auto', p: 0.5 }}
|
||||
onClick={() =>
|
||||
onPullLineItem({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Box, Typography, Skeleton, Chip } from '@mui/material';
|
||||
import apiClient from '../../lib/api/client';
|
||||
import { formatCurrency } from '../../lib/format';
|
||||
|
||||
interface VendorProposal {
|
||||
id: string;
|
||||
|
|
@ -63,7 +64,7 @@ export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
|
|||
|
||||
{vp.totalVendorCost > 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -76,7 +77,7 @@ export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
|
|||
</Typography>
|
||||
{li.total != null && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
${li.total.toFixed(2)}
|
||||
{formatCurrency(li.total)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'suc
|
|||
Revised: 'warning',
|
||||
};
|
||||
|
||||
export const DRAWER_WIDTH = 220;
|
||||
|
||||
export const PRIORITY_COLORS: Record<string, 'default' | 'warning' | 'error'> = {
|
||||
Standard: 'default',
|
||||
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.serviceCategory) params.append('serviceCategory', filters.serviceCategory);
|
||||
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');
|
||||
|
||||
const res = await apiClient.get(`/proposals?${params.toString()}`);
|
||||
|
|
@ -95,11 +97,17 @@ export const proposalsApi = {
|
|||
return res.data;
|
||||
},
|
||||
|
||||
uploadAttachment: async (proposalId: string, fileName: string): Promise<{ uploadUrl: string; s3Key: string }> => {
|
||||
const res = await apiClient.post(`/proposals/${proposalId}/attachments?fileName=${encodeURIComponent(fileName)}`);
|
||||
uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise<{ uploadUrl: string; s3Key: string; vendorProposalId: string }> => {
|
||||
const params = new URLSearchParams({ fileName });
|
||||
if (vendorName) params.append('vendorName', vendorName);
|
||||
const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
confirmUpload: async (proposalId: string, vendorProposalId: string): Promise<void> => {
|
||||
await apiClient.post(`/proposals/${proposalId}/attachments/${vendorProposalId}/confirm`);
|
||||
},
|
||||
|
||||
getStats: async (): Promise<ProposalStats> => {
|
||||
const res = await apiClient.get('/proposals/stats');
|
||||
return res.data;
|
||||
|
|
|
|||
|
|
@ -29,8 +29,9 @@ import { usePaginatedList } from '../../../hooks/usePaginatedList';
|
|||
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
|
||||
import { adminApi, type DashboardStats } from '../../../lib/api/admin';
|
||||
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 { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||
|
||||
function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) {
|
||||
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 { data: stats, isLoading: statsLoading } = useQuery<DashboardStats>({
|
||||
|
|
@ -56,6 +58,7 @@ export default function AdminDashboard() {
|
|||
queryFn: adminApi.getDashboard,
|
||||
});
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState(defaultStatus ?? '');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [priorityFilter, setPriorityFilter] = useState('');
|
||||
|
||||
|
|
@ -71,6 +74,7 @@ export default function AdminDashboard() {
|
|||
loading,
|
||||
err,
|
||||
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, {
|
||||
...(statusFilter && { status: statusFilter }),
|
||||
...(categoryFilter && { serviceCategory: categoryFilter }),
|
||||
...(priorityFilter && { priority: priorityFilter }),
|
||||
});
|
||||
|
|
@ -175,6 +179,19 @@ export default function AdminDashboard() {
|
|||
<MenuItem key={p} value={p}>{p}</MenuItem>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{err && (
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ export default function AdminWorkspace() {
|
|||
const [lineItems, setLineItems] = useState<EditableLineItem[]>([]);
|
||||
const [refinedScope, setRefinedScope] = useState('');
|
||||
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||
const [reviseDialogOpen, setReviseDialogOpen] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const { data: proposal, isLoading } = useQuery<ProposalDetail>({
|
||||
|
|
@ -144,6 +146,7 @@ export default function AdminWorkspace() {
|
|||
mutationFn: () => adminApi.sendProposal(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
|
||||
setSendDialogOpen(false);
|
||||
toast.success('Proposal marked as sent');
|
||||
},
|
||||
});
|
||||
|
|
@ -152,6 +155,7 @@ export default function AdminWorkspace() {
|
|||
mutationFn: () => adminApi.reviseProposal(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
|
||||
setReviseDialogOpen(false);
|
||||
toast.success('Revision created');
|
||||
},
|
||||
});
|
||||
|
|
@ -383,7 +387,7 @@ export default function AdminWorkspace() {
|
|||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<SendIcon />}
|
||||
onClick={() => sendMutation.mutate()}
|
||||
onClick={() => setSendDialogOpen(true)}
|
||||
disabled={sendMutation.isPending}
|
||||
>
|
||||
Mark as Sent
|
||||
|
|
@ -395,7 +399,7 @@ export default function AdminWorkspace() {
|
|||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<HistoryIcon />}
|
||||
onClick={() => reviseMutation.mutate()}
|
||||
onClick={() => setReviseDialogOpen(true)}
|
||||
disabled={reviseMutation.isPending}
|
||||
>
|
||||
Create Revision
|
||||
|
|
@ -449,6 +453,49 @@ export default function AdminWorkspace() {
|
|||
</Button>
|
||||
</DialogActions>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
||||
</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>
|
||||
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box>
|
||||
|
|
@ -87,13 +90,19 @@ export default function ProposalDetailPage() {
|
|||
<Typography variant="subtitle2" sx={{ mb: 2 }}>
|
||||
Status Timeline
|
||||
</Typography>
|
||||
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
|
||||
{STATUS_ORDER.map((label) => (
|
||||
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
{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>
|
||||
{STATUS_ORDER.map((label) => (
|
||||
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
)}
|
||||
</CardContent>
|
||||
</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 { useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
|
|
@ -39,6 +39,12 @@ export default function ProposalFormPage() {
|
|||
const [vendorFile, setVendorFile] = useState<File | 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) => {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue