mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-06 00:41:59 +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 System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
@ -94,40 +95,133 @@ public class FilesController : ControllerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("pdf")]
|
[HttpGet("pdf")]
|
||||||
[Authorize(Roles = "admins,sysadmins")]
|
public async Task<ActionResult<PdfDownloadResponse>> GetPdf(Guid proposalId, [FromQuery] bool regenerate = false, CancellationToken ct = default)
|
||||||
public async Task<ActionResult<PdfDownloadResponse>> GetPdf(Guid proposalId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
var pdf = await _db.GeneratedPdfs
|
var devMode = _config.GetValue<bool>("Auth:DevMode");
|
||||||
|
|
||||||
|
var pdf = regenerate ? null : await _db.GeneratedPdfs
|
||||||
.Where(p => p.ProposalId == proposalId)
|
.Where(p => p.ProposalId == proposalId)
|
||||||
.OrderByDescending(p => p.Revision)
|
.OrderByDescending(p => p.Revision)
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
if (pdf == null)
|
if (pdf != null && devMode)
|
||||||
{
|
{
|
||||||
await _jobPublisher.PublishAsync("pdf-generate", new { proposalId }, ct);
|
var localPath = Path.Combine(_getGeneratedPdfsDir(), pdf.S3Key);
|
||||||
return Accepted(new { message = "PDF generation queued" });
|
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"]!;
|
if (pdf != null && !devMode)
|
||||||
var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60);
|
{
|
||||||
|
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}")]
|
[HttpGet("pdf/{revision:int}")]
|
||||||
[Authorize(Roles = "admins,sysadmins")]
|
|
||||||
public async Task<ActionResult<PdfDownloadResponse>> GetPdfRevision(
|
public async Task<ActionResult<PdfDownloadResponse>> GetPdfRevision(
|
||||||
Guid proposalId,
|
Guid proposalId,
|
||||||
int revision,
|
int revision,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var devMode = _config.GetValue<bool>("Auth:DevMode");
|
||||||
|
|
||||||
var pdf = await _db.GeneratedPdfs
|
var pdf = await _db.GeneratedPdfs
|
||||||
.FirstOrDefaultAsync(p => p.ProposalId == proposalId && p.Revision == revision, ct);
|
.FirstOrDefaultAsync(p => p.ProposalId == proposalId && p.Revision == revision, ct);
|
||||||
|
|
||||||
if (pdf == null) return NotFound();
|
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 bucket = _config["GENERATED_BUCKET"]!;
|
||||||
var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60);
|
var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,14 @@ public class ProposalsController : ControllerBase
|
||||||
return Ok(result);
|
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")]
|
[HttpPost("{id:guid}/send")]
|
||||||
[Authorize(Roles = "admins,sysadmins")]
|
[Authorize(Roles = "admins,sysadmins")]
|
||||||
public async Task<ActionResult<ProposalResponse>> MarkSent(Guid id, CancellationToken ct)
|
public async Task<ActionResult<ProposalResponse>> MarkSent(Guid id, CancellationToken ct)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ public record PdfDownloadResponse(
|
||||||
DateTime ExpiresAt
|
DateTime ExpiresAt
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record PdfVersionResponse(
|
||||||
|
int Revision,
|
||||||
|
DateTime GeneratedAt
|
||||||
|
);
|
||||||
|
|
||||||
public record VendorProposalResponse(
|
public record VendorProposalResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string VendorName,
|
string VendorName,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ public interface IProposalService
|
||||||
Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default);
|
Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default);
|
||||||
Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default);
|
Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default);
|
||||||
Task<ProposalResponse> ApproveAsync(Guid id, 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> MarkSentAsync(Guid id, CancellationToken ct = default);
|
||||||
Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default);
|
Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default);
|
Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ public enum AuditAction
|
||||||
GeneratePDF,
|
GeneratePDF,
|
||||||
MarkSent,
|
MarkSent,
|
||||||
CreateRevision,
|
CreateRevision,
|
||||||
UpdateRole
|
UpdateRole,
|
||||||
|
ReturnToReview
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AuditLog
|
public class AuditLog
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,9 @@ secrets_client = boto3.client("secretsmanager")
|
||||||
_cached_api_key: str | None = None
|
_cached_api_key: str | None = None
|
||||||
|
|
||||||
COMPANY_NAME = "Sea Haven Industries"
|
COMPANY_NAME = "Sea Haven Industries"
|
||||||
COMPANY_ADDRESS = "Sea Haven Industries LLC"
|
COMPANY_ADDRESS = "710 Koehler Ave, Ronkonkoma, NY 11779"
|
||||||
COMPANY_PHONE = ""
|
COMPANY_PHONE = "(631) 776-5102"
|
||||||
COMPANY_EMAIL = "info@seahavenind.com"
|
COMPANY_EMAIL = "work-orders@seahaven.com"
|
||||||
|
|
||||||
TERMS_AND_CONDITIONS = """
|
TERMS_AND_CONDITIONS = """
|
||||||
1. This proposal is valid for 30 days from the date of issue.
|
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 = proposal.get("currentRevision", 1)
|
||||||
revision_text = f" | Rev {revision}" if revision > 1 else ""
|
revision_text = f" | Rev {revision}" if revision > 1 else ""
|
||||||
|
|
||||||
|
contact_lines = (
|
||||||
|
f"{COMPANY_ADDRESS}<br/>"
|
||||||
|
f"{COMPANY_PHONE} | {COMPANY_EMAIL}"
|
||||||
|
)
|
||||||
|
|
||||||
header_data = [
|
header_data = [
|
||||||
[
|
[
|
||||||
Paragraph(COMPANY_NAME, styles["CompanyName"]),
|
Paragraph(COMPANY_NAME, styles["CompanyName"]),
|
||||||
Paragraph(f"PROPOSAL{revision_text}", styles["ProposalTitle"]),
|
Paragraph(f"PROPOSAL{revision_text}", styles["ProposalTitle"]),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
Paragraph(f"{COMPANY_EMAIL}", styles["CompanyInfo"]),
|
Paragraph(contact_lines, styles["CompanyInfo"]),
|
||||||
Paragraph(f"#{proposal['proposalNumber']}", styles["MetaValue"]),
|
Paragraph(f"#{proposal['proposalNumber']}", styles["MetaValue"]),
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
@ -328,10 +333,12 @@ def _build_metadata(proposal: dict, styles) -> list:
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
po_number = proposal.get("poNumber") or ""
|
||||||
|
|
||||||
meta_data = [
|
meta_data = [
|
||||||
[
|
[
|
||||||
Paragraph("Customer", styles["MetaLabel"]),
|
Paragraph("Customer", styles["MetaLabel"]),
|
||||||
Paragraph("Site Address", styles["MetaLabel"]),
|
Paragraph("Site", styles["MetaLabel"]),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
Paragraph(proposal.get("customerName", ""), styles["MetaValue"]),
|
Paragraph(proposal.get("customerName", ""), styles["MetaValue"]),
|
||||||
|
|
@ -339,19 +346,27 @@ def _build_metadata(proposal: dict, styles) -> list:
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
Paragraph("Work Order #", styles["MetaLabel"]),
|
Paragraph("Work Order #", styles["MetaLabel"]),
|
||||||
Paragraph("Date", styles["MetaLabel"]),
|
Paragraph("PO #" if po_number else "", styles["MetaLabel"]),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
Paragraph(proposal.get("workOrderNumber", ""), styles["MetaValue"]),
|
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("Category", styles["MetaLabel"]),
|
||||||
Paragraph("Priority", styles["MetaLabel"]),
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
Paragraph(approved_at or submitted_at, styles["MetaValue"]),
|
||||||
Paragraph(proposal.get("serviceCategory", ""), styles["MetaValue"]),
|
Paragraph(proposal.get("serviceCategory", ""), styles["MetaValue"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Paragraph("Priority", styles["MetaLabel"]),
|
||||||
|
Paragraph("", styles["MetaLabel"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
Paragraph(proposal.get("priority", ""), styles["MetaValue"]),
|
Paragraph(proposal.get("priority", ""), styles["MetaValue"]),
|
||||||
|
Paragraph("", styles["MetaValue"]),
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import apiClient from './client';
|
||||||
|
|
||||||
export interface CreateProposalRequest {
|
export interface CreateProposalRequest {
|
||||||
workOrderNumber: string;
|
workOrderNumber: string;
|
||||||
|
poNumber?: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerAddress: string;
|
customerAddress: string;
|
||||||
scopeOfWork: string;
|
scopeOfWork: string;
|
||||||
|
|
@ -28,6 +29,7 @@ export interface ProposalDetail {
|
||||||
id: string;
|
id: string;
|
||||||
proposalNumber: string;
|
proposalNumber: string;
|
||||||
workOrderNumber: string;
|
workOrderNumber: string;
|
||||||
|
poNumber: string | null;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerAddress: string;
|
customerAddress: string;
|
||||||
scopeOfWork: string;
|
scopeOfWork: string;
|
||||||
|
|
@ -112,6 +114,33 @@ export const proposalsApi = {
|
||||||
const res = await apiClient.get('/proposals/stats');
|
const res = await apiClient.get('/proposals/stats');
|
||||||
return res.data;
|
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 {
|
export interface ProposalStats {
|
||||||
|
|
@ -120,3 +149,8 @@ export interface ProposalStats {
|
||||||
approvedCount: number;
|
approvedCount: number;
|
||||||
sentCount: 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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,14 +10,20 @@ import {
|
||||||
Chip,
|
Chip,
|
||||||
Divider,
|
Divider,
|
||||||
Button,
|
Button,
|
||||||
|
IconButton,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stepper,
|
Stepper,
|
||||||
Step,
|
Step,
|
||||||
StepLabel,
|
StepLabel,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Tooltip,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
import { STATUS_COLORS } from '../../../constants';
|
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 { formatCurrency, formatDateTime } from '../../../lib/format';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
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() {
|
export default function ProposalDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [downloadingRevision, setDownloadingRevision] = useState<number | null>(null);
|
||||||
|
|
||||||
const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({
|
const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({
|
||||||
queryKey: ['proposals', id],
|
queryKey: ['proposals', id],
|
||||||
|
|
@ -43,8 +54,48 @@ export default function ProposalDetailPage() {
|
||||||
enabled: !!id,
|
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');
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|
@ -78,8 +129,18 @@ export default function ProposalDetailPage() {
|
||||||
<Typography variant="h5" sx={{ flexGrow: 1 }}>
|
<Typography variant="h5" sx={{ flexGrow: 1 }}>
|
||||||
{proposal.proposalNumber}
|
{proposal.proposalNumber}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{DOWNLOADABLE_STATUSES.includes(proposal.status) && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={downloading}
|
||||||
|
>
|
||||||
|
{downloading ? 'Downloading...' : 'Download PDF'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Chip
|
<Chip
|
||||||
label={proposal.status}
|
label={STATUS_LABELS[proposal.status] || proposal.status}
|
||||||
color={STATUS_COLORS[proposal.status] || 'default'}
|
color={STATUS_COLORS[proposal.status] || 'default'}
|
||||||
size="medium"
|
size="medium"
|
||||||
/>
|
/>
|
||||||
|
|
@ -98,7 +159,7 @@ export default function ProposalDetailPage() {
|
||||||
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
|
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
|
||||||
{STATUS_ORDER.map((label) => (
|
{STATUS_ORDER.map((label) => (
|
||||||
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
|
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
|
||||||
<StepLabel>{label}</StepLabel>
|
<StepLabel>{STATUS_LABELS[label] || label}</StepLabel>
|
||||||
</Step>
|
</Step>
|
||||||
))}
|
))}
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|
@ -112,11 +173,11 @@ export default function ProposalDetailPage() {
|
||||||
Proposal Details
|
Proposal Details
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2}>
|
<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="Customer" value={proposal.customerName} />
|
||||||
<InfoRow label="Address" value={proposal.customerAddress} />
|
<InfoRow label="Site" value={proposal.customerAddress} />
|
||||||
<InfoRow label="Category" value={proposal.serviceCategory} />
|
<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="Revision" value={`#${proposal.currentRevision}`} />
|
||||||
<InfoRow label="Submitted By" value={proposal.submittedByName} />
|
<InfoRow label="Submitted By" value={proposal.submittedByName} />
|
||||||
<InfoRow label="Submitted" value={formatDateTime(proposal.submittedAt)} />
|
<InfoRow label="Submitted" value={formatDateTime(proposal.submittedAt)} />
|
||||||
|
|
@ -147,7 +208,7 @@ export default function ProposalDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{(proposal.approvedAt || proposal.sentAt) && (
|
{(proposal.approvedAt || proposal.sentAt) && (
|
||||||
<Card>
|
<Card sx={{ mb: 3 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 2 }}>
|
<Typography variant="subtitle2" sx={{ mb: 2 }}>
|
||||||
Approval & Delivery
|
Approval & Delivery
|
||||||
|
|
@ -162,6 +223,39 @@ export default function ProposalDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue