mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 21:43:14 +00:00
Add PDF download for all roles, version history, and status timeline fix
- Removed admin-only restriction on PDF download endpoints - Added GET pdf/versions endpoint returning all generated PDFs - Download PDF button on detail page for Approved/Sent/Revised proposals - PDF Versions card shows all revisions with individual download buttons - Dev-mode support for GetPdfRevision endpoint - Status timeline stepper now uses STATUS_LABELS (fixes "InReview" display) - PDF Lambda improvements for local generation
This commit is contained in:
parent
666e2bad9e
commit
cab97cbb1b
8 changed files with 280 additions and 28 deletions
|
|
@ -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<ActionResult<PdfDownloadResponse>> GetPdf(Guid proposalId, CancellationToken ct)
|
||||
public async Task<ActionResult<PdfDownloadResponse>> GetPdf(Guid proposalId, [FromQuery] bool regenerate = false, CancellationToken ct = default)
|
||||
{
|
||||
var pdf = await _db.GeneratedPdfs
|
||||
var devMode = _config.GetValue<bool>("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<ActionResult> _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<JsonElement>(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<ActionResult<IReadOnlyList<PdfVersionResponse>>> 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<ActionResult<PdfDownloadResponse>> GetPdfRevision(
|
||||
Guid proposalId,
|
||||
int revision,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var devMode = _config.GetValue<bool>("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);
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ public class ProposalsController : ControllerBase
|
|||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/return-to-review")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<ActionResult<ProposalResponse>> 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<ActionResult<ProposalResponse>> MarkSent(Guid id, CancellationToken ct)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ public record PdfDownloadResponse(
|
|||
DateTime ExpiresAt
|
||||
);
|
||||
|
||||
public record PdfVersionResponse(
|
||||
int Revision,
|
||||
DateTime GeneratedAt
|
||||
);
|
||||
|
||||
public record VendorProposalResponse(
|
||||
Guid Id,
|
||||
string VendorName,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ public interface IProposalService
|
|||
Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default);
|
||||
Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default);
|
||||
Task<ProposalResponse> ApproveAsync(Guid id, CancellationToken ct = default);
|
||||
Task<ProposalResponse> ReturnToReviewAsync(Guid id, CancellationToken ct = default);
|
||||
Task<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default);
|
||||
Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ public enum AuditAction
|
|||
GeneratePDF,
|
||||
MarkSent,
|
||||
CreateRevision,
|
||||
UpdateRole
|
||||
UpdateRole,
|
||||
ReturnToReview
|
||||
}
|
||||
|
||||
public class AuditLog
|
||||
|
|
|
|||
|
|
@ -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}<br/>"
|
||||
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"]),
|
||||
],
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PdfVersion[]> => {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<number | null>(null);
|
||||
|
||||
const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({
|
||||
queryKey: ['proposals', id],
|
||||
|
|
@ -43,8 +54,48 @@ export default function ProposalDetailPage() {
|
|||
enabled: !!id,
|
||||
});
|
||||
|
||||
const { data: pdfVersions } = useQuery<PdfVersion[]>({
|
||||
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 (
|
||||
<Box>
|
||||
|
|
@ -78,8 +129,18 @@ export default function ProposalDetailPage() {
|
|||
<Typography variant="h5" sx={{ flexGrow: 1 }}>
|
||||
{proposal.proposalNumber}
|
||||
</Typography>
|
||||
{DOWNLOADABLE_STATUSES.includes(proposal.status) && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? 'Downloading...' : 'Download PDF'}
|
||||
</Button>
|
||||
)}
|
||||
<Chip
|
||||
label={proposal.status}
|
||||
label={STATUS_LABELS[proposal.status] || proposal.status}
|
||||
color={STATUS_COLORS[proposal.status] || 'default'}
|
||||
size="medium"
|
||||
/>
|
||||
|
|
@ -98,7 +159,7 @@ export default function ProposalDetailPage() {
|
|||
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
|
||||
{STATUS_ORDER.map((label) => (
|
||||
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
<StepLabel>{STATUS_LABELS[label] || label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
|
@ -112,11 +173,11 @@ export default function ProposalDetailPage() {
|
|||
Proposal Details
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<InfoRow label="Work Order #" value={proposal.workOrderNumber} />
|
||||
<InfoRow label="Work Order Number" value={proposal.workOrderNumber} />
|
||||
<InfoRow label="Customer" value={proposal.customerName} />
|
||||
<InfoRow label="Address" value={proposal.customerAddress} />
|
||||
<InfoRow label="Site" value={proposal.customerAddress} />
|
||||
<InfoRow label="Category" value={proposal.serviceCategory} />
|
||||
<InfoRow label="Priority" value={proposal.priority} />
|
||||
<InfoRow label="Priority" value={PRIORITY_LABELS[proposal.priority] || proposal.priority} />
|
||||
<InfoRow label="Revision" value={`#${proposal.currentRevision}`} />
|
||||
<InfoRow label="Submitted By" value={proposal.submittedByName} />
|
||||
<InfoRow label="Submitted" value={formatDateTime(proposal.submittedAt)} />
|
||||
|
|
@ -147,7 +208,7 @@ export default function ProposalDetailPage() {
|
|||
</Card>
|
||||
|
||||
{(proposal.approvedAt || proposal.sentAt) && (
|
||||
<Card>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2 }}>
|
||||
Approval & Delivery
|
||||
|
|
@ -162,6 +223,39 @@ export default function ProposalDetailPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{pdfVersions && pdfVersions.length > 1 && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
PDF Versions
|
||||
</Typography>
|
||||
<List dense disablePadding>
|
||||
{pdfVersions.map((v) => (
|
||||
<ListItem
|
||||
key={v.revision}
|
||||
secondaryAction={
|
||||
<Tooltip title={`Download Rev ${v.revision}`}>
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleDownloadRevision(v.revision)}
|
||||
disabled={downloadingRevision === v.revision}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={`Revision ${v.revision}${v.revision === proposal.currentRevision ? ' (current)' : ''}`}
|
||||
secondary={formatDateTime(v.generatedAt)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue