diff --git a/api/src/ProposalSystem.Api/Controllers/FilesController.cs b/api/src/ProposalSystem.Api/Controllers/FilesController.cs index fdbf3a9..2df9496 100644 --- a/api/src/ProposalSystem.Api/Controllers/FilesController.cs +++ b/api/src/ProposalSystem.Api/Controllers/FilesController.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -94,40 +95,133 @@ public class FilesController : ControllerBase } [HttpGet("pdf")] - [Authorize(Roles = "admins,sysadmins")] - public async Task> GetPdf(Guid proposalId, CancellationToken ct) + public async Task> GetPdf(Guid proposalId, [FromQuery] bool regenerate = false, CancellationToken ct = default) { - var pdf = await _db.GeneratedPdfs + var devMode = _config.GetValue("Auth:DevMode"); + + var pdf = regenerate ? null : await _db.GeneratedPdfs .Where(p => p.ProposalId == proposalId) .OrderByDescending(p => p.Revision) .FirstOrDefaultAsync(ct); - if (pdf == null) + if (pdf != null && devMode) { - await _jobPublisher.PublishAsync("pdf-generate", new { proposalId }, ct); - return Accepted(new { message = "PDF generation queued" }); + var localPath = Path.Combine(_getGeneratedPdfsDir(), pdf.S3Key); + if (System.IO.File.Exists(localPath)) + { + await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {pdf.Revision}", ct); + return PhysicalFile(localPath, "application/pdf", Path.GetFileName(pdf.S3Key)); + } } - var bucket = _config["GENERATED_BUCKET"]!; - var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60); + if (pdf != null && !devMode) + { + var bucket = _config["GENERATED_BUCKET"]!; + var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60); + await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {pdf.Revision}", ct); + return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60))); + } - await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {pdf.Revision}", ct); + if (devMode) + { + return await _generatePdfLocally(proposalId, ct); + } - return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60))); + await _jobPublisher.PublishAsync("pdf-generate", new { proposalId }, ct); + return Accepted(new { message = "PDF generation queued" }); + } + + private async Task _generatePdfLocally(Guid proposalId, CancellationToken ct) + { + var outputDir = _getGeneratedPdfsDir(); + var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..")); + var scriptPath = Path.Combine(repoRoot, "scripts", "generate-pdf-local.py"); + + var psi = new ProcessStartInfo + { + FileName = "python3", + Arguments = $"\"{scriptPath}\" {proposalId} \"{outputDir}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + using var process = Process.Start(psi)!; + var stdout = await process.StandardOutput.ReadToEndAsync(ct); + var stderr = await process.StandardError.ReadToEndAsync(ct); + await process.WaitForExitAsync(ct); + + if (process.ExitCode != 0) + return StatusCode(500, new { message = "PDF generation failed", detail = stderr }); + + var result = JsonSerializer.Deserialize(stdout.Trim()); + var filePath = result.GetProperty("path").GetString()!; + var s3Key = result.GetProperty("s3Key").GetString()!; + + var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct); + if (proposal == null) return NotFound(); + + var generatedPdf = new GeneratedPdf + { + Id = Guid.NewGuid(), + ProposalId = proposalId, + Revision = proposal.CurrentRevision, + S3Key = s3Key, + GeneratedAt = DateTime.UtcNow, + GeneratedById = Guid.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value), + }; + + _db.GeneratedPdfs.Add(generatedPdf); + await _db.SaveChangesAsync(ct); + await _audit.LogAsync(AuditAction.GeneratePDF, proposalId, null, ct); + + return PhysicalFile(filePath, "application/pdf", Path.GetFileName(filePath)); + } + + private string _getGeneratedPdfsDir() + { + var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..")); + return Path.Combine(repoRoot, "generated-pdfs"); + } + + [HttpGet("pdf/versions")] + public async Task>> GetPdfVersions( + Guid proposalId, + CancellationToken ct) + { + var pdfs = await _db.GeneratedPdfs + .Where(p => p.ProposalId == proposalId) + .OrderByDescending(p => p.Revision) + .Select(p => new PdfVersionResponse(p.Revision, p.GeneratedAt)) + .ToListAsync(ct); + + return Ok(pdfs); } [HttpGet("pdf/{revision:int}")] - [Authorize(Roles = "admins,sysadmins")] public async Task> GetPdfRevision( Guid proposalId, int revision, CancellationToken ct) { + var devMode = _config.GetValue("Auth:DevMode"); + var pdf = await _db.GeneratedPdfs .FirstOrDefaultAsync(p => p.ProposalId == proposalId && p.Revision == revision, ct); if (pdf == null) return NotFound(); + if (devMode) + { + var localPath = Path.Combine(_getGeneratedPdfsDir(), pdf.S3Key); + if (System.IO.File.Exists(localPath)) + { + await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {revision}", ct); + return PhysicalFile(localPath, "application/pdf", Path.GetFileName(pdf.S3Key)); + } + return NotFound(); + } + var bucket = _config["GENERATED_BUCKET"]!; var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60); diff --git a/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs b/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs index 899cbd9..274a4be 100644 --- a/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs +++ b/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs @@ -69,6 +69,14 @@ public class ProposalsController : ControllerBase return Ok(result); } + [HttpPost("{id:guid}/return-to-review")] + [Authorize(Roles = "admins,sysadmins")] + public async Task> ReturnToReview(Guid id, CancellationToken ct) + { + var result = await _proposalService.ReturnToReviewAsync(id, ct); + return Ok(result); + } + [HttpPost("{id:guid}/send")] [Authorize(Roles = "admins,sysadmins")] public async Task> MarkSent(Guid id, CancellationToken ct) diff --git a/api/src/ProposalSystem.Application/DTOs/FileDtos.cs b/api/src/ProposalSystem.Application/DTOs/FileDtos.cs index c5acfb0..89ff5df 100644 --- a/api/src/ProposalSystem.Application/DTOs/FileDtos.cs +++ b/api/src/ProposalSystem.Application/DTOs/FileDtos.cs @@ -12,6 +12,11 @@ public record PdfDownloadResponse( DateTime ExpiresAt ); +public record PdfVersionResponse( + int Revision, + DateTime GeneratedAt +); + public record VendorProposalResponse( Guid Id, string VendorName, diff --git a/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs b/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs index 47f9b00..fbcfef6 100644 --- a/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs +++ b/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs @@ -9,6 +9,7 @@ public interface IProposalService Task> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default); Task UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default); Task ApproveAsync(Guid id, CancellationToken ct = default); + Task ReturnToReviewAsync(Guid id, CancellationToken ct = default); Task MarkSentAsync(Guid id, CancellationToken ct = default); Task ReviseAsync(Guid id, CancellationToken ct = default); Task> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default); diff --git a/api/src/ProposalSystem.Domain/Entities/AuditLog.cs b/api/src/ProposalSystem.Domain/Entities/AuditLog.cs index c5d9e0e..1bfe81a 100644 --- a/api/src/ProposalSystem.Domain/Entities/AuditLog.cs +++ b/api/src/ProposalSystem.Domain/Entities/AuditLog.cs @@ -14,7 +14,8 @@ public enum AuditAction GeneratePDF, MarkSent, CreateRevision, - UpdateRole + UpdateRole, + ReturnToReview } public class AuditLog diff --git a/lambdas/pdf-generate/app.py b/lambdas/pdf-generate/app.py index 6583b46..e8b056a 100644 --- a/lambdas/pdf-generate/app.py +++ b/lambdas/pdf-generate/app.py @@ -39,9 +39,9 @@ secrets_client = boto3.client("secretsmanager") _cached_api_key: str | None = None COMPANY_NAME = "Sea Haven Industries" -COMPANY_ADDRESS = "Sea Haven Industries LLC" -COMPANY_PHONE = "" -COMPANY_EMAIL = "info@seahavenind.com" +COMPANY_ADDRESS = "710 Koehler Ave, Ronkonkoma, NY 11779" +COMPANY_PHONE = "(631) 776-5102" +COMPANY_EMAIL = "work-orders@seahaven.com" TERMS_AND_CONDITIONS = """ 1. This proposal is valid for 30 days from the date of issue. @@ -285,13 +285,18 @@ def _build_header(proposal: dict, styles) -> list: revision = proposal.get("currentRevision", 1) revision_text = f" | Rev {revision}" if revision > 1 else "" + contact_lines = ( + f"{COMPANY_ADDRESS}
" + f"{COMPANY_PHONE} | {COMPANY_EMAIL}" + ) + header_data = [ [ Paragraph(COMPANY_NAME, styles["CompanyName"]), Paragraph(f"PROPOSAL{revision_text}", styles["ProposalTitle"]), ], [ - Paragraph(f"{COMPANY_EMAIL}", styles["CompanyInfo"]), + Paragraph(contact_lines, styles["CompanyInfo"]), Paragraph(f"#{proposal['proposalNumber']}", styles["MetaValue"]), ], ] @@ -328,10 +333,12 @@ def _build_metadata(proposal: dict, styles) -> list: except (ValueError, TypeError): pass + po_number = proposal.get("poNumber") or "" + meta_data = [ [ Paragraph("Customer", styles["MetaLabel"]), - Paragraph("Site Address", styles["MetaLabel"]), + Paragraph("Site", styles["MetaLabel"]), ], [ Paragraph(proposal.get("customerName", ""), styles["MetaValue"]), @@ -339,19 +346,27 @@ def _build_metadata(proposal: dict, styles) -> list: ], [ Paragraph("Work Order #", styles["MetaLabel"]), - Paragraph("Date", styles["MetaLabel"]), + Paragraph("PO #" if po_number else "", styles["MetaLabel"]), ], [ Paragraph(proposal.get("workOrderNumber", ""), styles["MetaValue"]), - Paragraph(approved_at or submitted_at, styles["MetaValue"]), + Paragraph(po_number, styles["MetaValue"]), ], [ + Paragraph("Date", styles["MetaLabel"]), Paragraph("Category", styles["MetaLabel"]), - Paragraph("Priority", styles["MetaLabel"]), ], [ + Paragraph(approved_at or submitted_at, styles["MetaValue"]), Paragraph(proposal.get("serviceCategory", ""), styles["MetaValue"]), + ], + [ + Paragraph("Priority", styles["MetaLabel"]), + Paragraph("", styles["MetaLabel"]), + ], + [ Paragraph(proposal.get("priority", ""), styles["MetaValue"]), + Paragraph("", styles["MetaValue"]), ], ] diff --git a/web/src/lib/api/proposals.ts b/web/src/lib/api/proposals.ts index 462957f..c495a43 100644 --- a/web/src/lib/api/proposals.ts +++ b/web/src/lib/api/proposals.ts @@ -2,6 +2,7 @@ import apiClient from './client'; export interface CreateProposalRequest { workOrderNumber: string; + poNumber?: string; customerName: string; customerAddress: string; scopeOfWork: string; @@ -28,6 +29,7 @@ export interface ProposalDetail { id: string; proposalNumber: string; workOrderNumber: string; + poNumber: string | null; customerName: string; customerAddress: string; scopeOfWork: string; @@ -112,6 +114,33 @@ export const proposalsApi = { const res = await apiClient.get('/proposals/stats'); return res.data; }, + + getPdf: async (id: string): Promise<{ downloadUrl: string } | null> => { + const res = await apiClient.get(`/proposals/${id}/pdf`, { + responseType: 'blob', + validateStatus: (status) => status < 500, + }); + if (res.status === 404) return null; + const blob = res.data as Blob; + const downloadUrl = URL.createObjectURL(blob); + return { downloadUrl }; + }, + + getPdfVersions: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}/pdf/versions`); + return res.data; + }, + + getPdfRevision: async (id: string, revision: number): Promise<{ downloadUrl: string } | null> => { + const res = await apiClient.get(`/proposals/${id}/pdf/${revision}`, { + responseType: 'blob', + validateStatus: (status) => status < 500, + }); + if (res.status === 404) return null; + const blob = res.data as Blob; + const downloadUrl = URL.createObjectURL(blob); + return { downloadUrl }; + }, }; export interface ProposalStats { @@ -120,3 +149,8 @@ export interface ProposalStats { approvedCount: number; sentCount: number; } + +export interface PdfVersion { + revision: number; + generatedAt: string; +} diff --git a/web/src/pages/proposals/detail/ProposalDetailPage.tsx b/web/src/pages/proposals/detail/ProposalDetailPage.tsx index 2d580f2..ac2e6a3 100644 --- a/web/src/pages/proposals/detail/ProposalDetailPage.tsx +++ b/web/src/pages/proposals/detail/ProposalDetailPage.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { @@ -9,14 +10,20 @@ import { Chip, Divider, Button, + IconButton, Skeleton, Stepper, Step, StepLabel, + List, + ListItem, + ListItemText, + Tooltip, } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; -import { STATUS_COLORS } from '../../../constants'; +import DownloadIcon from '@mui/icons-material/Download'; +import { proposalsApi, type ProposalDetail, type PdfVersion } from '../../../lib/api/proposals'; +import { STATUS_COLORS, STATUS_LABELS, PRIORITY_LABELS } from '../../../constants'; import { formatCurrency, formatDateTime } from '../../../lib/format'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; @@ -33,9 +40,13 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { ); } +const DOWNLOADABLE_STATUSES = ['Approved', 'Sent', 'Revised']; + export default function ProposalDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const [downloading, setDownloading] = useState(false); + const [downloadingRevision, setDownloadingRevision] = useState(null); const { data: proposal, isLoading, error } = useQuery({ queryKey: ['proposals', id], @@ -43,8 +54,48 @@ export default function ProposalDetailPage() { enabled: !!id, }); + const { data: pdfVersions } = useQuery({ + queryKey: ['proposals', id, 'pdf-versions'], + queryFn: () => proposalsApi.getPdfVersions(id!), + enabled: !!id && !!proposal && DOWNLOADABLE_STATUSES.includes(proposal.status), + }); + useDocumentTitle(proposal?.proposalNumber ?? 'Proposal'); + const handleDownload = async () => { + if (!id || !proposal) return; + setDownloading(true); + try { + const result = await proposalsApi.getPdf(id); + if (result) { + const a = document.createElement('a'); + a.href = result.downloadUrl; + a.download = `${proposal.proposalNumber}.pdf`; + a.click(); + URL.revokeObjectURL(result.downloadUrl); + } + } finally { + setDownloading(false); + } + }; + + const handleDownloadRevision = async (revision: number) => { + if (!id || !proposal) return; + setDownloadingRevision(revision); + try { + const result = await proposalsApi.getPdfRevision(id, revision); + if (result) { + const a = document.createElement('a'); + a.href = result.downloadUrl; + a.download = `${proposal.proposalNumber}-rev${revision}.pdf`; + a.click(); + URL.revokeObjectURL(result.downloadUrl); + } + } finally { + setDownloadingRevision(null); + } + }; + if (isLoading) { return ( @@ -78,8 +129,18 @@ export default function ProposalDetailPage() { {proposal.proposalNumber} + {DOWNLOADABLE_STATUSES.includes(proposal.status) && ( + + )} @@ -98,7 +159,7 @@ export default function ProposalDetailPage() { = 0 ? activeStep : 0} alternativeLabel> {STATUS_ORDER.map((label) => ( - {label} + {STATUS_LABELS[label] || label} ))} @@ -112,11 +173,11 @@ export default function ProposalDetailPage() { Proposal Details - + - + - + @@ -147,7 +208,7 @@ export default function ProposalDetailPage() { {(proposal.approvedAt || proposal.sentAt) && ( - + Approval & Delivery @@ -162,6 +223,39 @@ export default function ProposalDetailPage() { )} + + {pdfVersions && pdfVersions.length > 1 && ( + + + + PDF Versions + + + {pdfVersions.map((v) => ( + + handleDownloadRevision(v.revision)} + disabled={downloadingRevision === v.revision} + > + + + + } + > + + + ))} + + + + )} ); }