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

## Summary
Implements Phase 3 of the AUDIT-2026-05-20 findings:
- 29 FIX-severity items across API, web, infra, and lambdas
- 7 accessibility NITs (aria-labels, document titles)
- 4 code quality NITs (deduplication, constants extraction)

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

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

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

View file

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

View file

@ -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);

View file

@ -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")]

View file

@ -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,
}

View file

@ -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);

View file

@ -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();
});

View file

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

View file

@ -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,

View file

@ -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);

View file

@ -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

View file

@ -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,
},

View file

@ -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()

View file

@ -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

View file

@ -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>} />

View file

@ -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;

View file

@ -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" /> },

View file

@ -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" />

View file

@ -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>

View file

@ -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({

View file

@ -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>

View file

@ -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',

View file

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

View file

@ -84,6 +84,8 @@ export const proposalsApi = {
if (filters.status) params.append('status', filters.status);
if (filters.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;

View file

@ -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 && (

View file

@ -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>
);
}

View file

@ -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">

View file

@ -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>

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { 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;

View file

@ -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 {