Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions Govt-Billing-React/src/components/InvoicePDF/InvoicePDFModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import React, { useState } from "react";
import {
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonContent,
IonButton,
IonIcon,
IonItem,
IonLabel,
IonList,
IonToast,
IonButtons,
IonText,
} from "@ionic/react";
import {
documentOutline,
downloadOutline,
printOutline,
closeOutline,
checkmarkCircleOutline,
} from "ionicons/icons";
import { useInvoicePDF } from "../../hooks/useInvoicePDF";

interface InvoicePDFModalProps {
show: boolean;
setShow: (val: boolean) => void;
filename: string;
}

const InvoicePDFModal: React.FC<InvoicePDFModalProps> = ({
show,
setShow,
filename,
}) => {
const { exportAsPDF, downloadHTML } = useInvoicePDF();
const [toastMessage, setToastMessage] = useState("");
const [showToast, setShowToast] = useState(false);

const handleExportPDF = () => {
// iframe-based print — no popup blocker issues, no try/catch needed
exportAsPDF(filename);
setToastMessage("Print dialog opening — choose 'Save as PDF' as destination.");
setShowToast(true);
setShow(false);
};

const handleDownloadHTML = () => {
try {
downloadHTML(filename);
setToastMessage("Invoice downloaded as HTML file.");
setShowToast(true);
setShow(false);
} catch {
setToastMessage("Download failed. Please try again.");
setShowToast(true);
}
};

return (
<>
<IonModal isOpen={show} onDidDismiss={() => setShow(false)}>
<IonHeader>
<IonToolbar color="primary">
<IonTitle>Export Invoice as PDF</IonTitle>
<IonButtons slot="end">
<IonButton onClick={() => setShow(false)}>
<IonIcon icon={closeOutline} />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>

<IonContent className="ion-padding">
<IonItem lines="none" className="ion-margin-bottom">
<IonIcon icon={documentOutline} slot="start" color="primary" />
<IonLabel>
<h2>Current Invoice</h2>
<p>{filename}</p>
</IonLabel>
</IonItem>

<IonText color="medium">
<p className="ion-padding-horizontal ion-padding-bottom">
Choose how you want to export this invoice. Both options include a
formatted header with the invoice name and export timestamp.
</p>
</IonText>

<IonList inset>
<IonItem button detail onClick={handleExportPDF} lines="full">
<IonIcon icon={printOutline} slot="start" color="primary" />
<IonLabel>
<h2>Export as PDF</h2>
<p>Opens print dialog — set destination to "Save as PDF"</p>
</IonLabel>
</IonItem>

<IonItem button detail onClick={handleDownloadHTML} lines="none">
<IonIcon icon={downloadOutline} slot="start" color="secondary" />
<IonLabel>
<h2>Download as HTML</h2>
<p>Self-contained file — open in any browser or email as attachment</p>
</IonLabel>
</IonItem>
</IonList>

<IonItem lines="none" className="ion-margin-top">
<IonIcon icon={checkmarkCircleOutline} slot="start" color="success" />
<IonLabel className="ion-text-wrap">
<p>
<strong>Tip:</strong> In the print dialog, set "Destination" to
"Save as PDF" and disable headers/footers for the cleanest output.
</p>
</IonLabel>
</IonItem>

<IonButton
expand="block"
fill="outline"
color="medium"
className="ion-margin-top"
onClick={() => setShow(false)}
>
Cancel
</IonButton>
</IonContent>
</IonModal>

<IonToast
isOpen={showToast}
onDidDismiss={() => setShowToast(false)}
message={toastMessage}
duration={3000}
position="bottom"
/>
</>
);
};

export default InvoicePDFModal;
19 changes: 17 additions & 2 deletions Govt-Billing-React/src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { isPlatform, IonToast } from "@ionic/react";
import { EmailComposer } from "capacitor-email-composer";
import { Printer } from "@ionic-native/printer";
import { IonActionSheet, IonAlert } from "@ionic/react";
import { saveOutline, save, mail, print } from "ionicons/icons";
import { saveOutline, save, mail, print, documentText } from "ionicons/icons";
import { APP_NAME } from "../../app-data.js";
import InvoicePDFModal from "../InvoicePDF/InvoicePDFModal";

const Menu: React.FC<{
showM: boolean;
Expand All @@ -16,6 +17,7 @@ const Menu: React.FC<{
store: Local;
bT: number;
}> = (props) => {
const [showPDFModal, setShowPDFModal] = useState(false);
const [showAlert1, setShowAlert1] = useState(false);
const [showAlert2, setShowAlert2] = useState(false);
const [showAlert3, setShowAlert3] = useState(false);
Expand Down Expand Up @@ -156,6 +158,14 @@ const Menu: React.FC<{
console.log("Save As clicked");
},
},
{
text: "Export as PDF",
icon: documentText,
handler: () => {
setShowPDFModal(true);
console.log("Export PDF clicked");
},
},
{
text: "Print",
icon: print,
Expand Down Expand Up @@ -236,8 +246,13 @@ const Menu: React.FC<{
message={toastMessage}
duration={500}
/>
<InvoicePDFModal
show={showPDFModal}
setShow={setShowPDFModal}
filename={props.file}
/>
</React.Fragment>
);
};

export default Menu;
export default Menu;
143 changes: 143 additions & 0 deletions Govt-Billing-React/src/hooks/useInvoicePDF.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { APP_NAME } from "../app-data";
import * as AppGeneral from "../components/socialcalc/index.js";

/**
* Takes the raw HTML string from SocialCalc and replaces any <canvas ...></canvas>
* elements with a placeholder note. No DOM mutation — pure string processing.
*
* Why: canvas pixel data can't survive HTML serialisation. Rather than
* attempting risky DOM swaps (which cause NotFoundError when React re-renders
* during the swap), we cleanly remove canvases and note their absence.
*/
const replaceCanvasesInHTML = (html: string): string => {
// Match <canvas ...>...</canvas> — covers both self-closing and paired tags
return html.replace(
/<canvas[^>]*>[\s\S]*?<\/canvas>/gi,
'<p style="color:#888;font-size:10px;font-style:italic;margin:4px 0;">[Chart — view in app]</p>'
);
};

const buildPDFHTML = (filename: string): string => {
const rawHTML = AppGeneral.getCurrentHTMLContent();
const sheetHTML = replaceCanvasesInHTML(rawHTML);
const exportedAt = new Date().toLocaleString();

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>${APP_NAME} — ${filename}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 12px;
color: #1a1a2e;
background: #fff;
padding: 32px 40px;
}
.pdf-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 3px solid #3880ff;
padding-bottom: 16px;
margin-bottom: 24px;
}
.pdf-header .org-block h1 { font-size: 22px; font-weight: 700; color: #3880ff; }
.pdf-header .org-block p { font-size: 11px; color: #555; margin-top: 2px; }
.pdf-header .meta-block { text-align: right; font-size: 11px; color: #444; line-height: 1.8; }
.pdf-header .meta-block .invoice-label {
font-size: 18px; font-weight: 700; color: #3880ff; display: block; margin-bottom: 4px;
}
.sheet-wrapper { overflow-x: auto; }
.sheet-wrapper table { width: 100%; border-collapse: collapse; font-size: 11px; }
.sheet-wrapper td, .sheet-wrapper th {
border: 1px solid #d0d8e8; padding: 5px 8px; vertical-align: top;
}
.sheet-wrapper tr:nth-child(even) td { background: #f5f8ff; }
.sheet-wrapper img { max-width: 100%; height: auto; display: block; margin: 8px 0; }
.pdf-footer {
margin-top: 28px; border-top: 1px solid #d0d8e8; padding-top: 12px;
display: flex; justify-content: space-between; font-size: 10px; color: #888;
}
@media print {
body { padding: 16px; }
.sheet-wrapper { overflow: visible; }
@page { margin: 1cm; size: A4; }
}
</style>
</head>
<body>
<div class="pdf-header">
<div class="org-block">
<h1>${APP_NAME}</h1>
<p>Government Billing &amp; Invoicing System</p>
</div>
<div class="meta-block">
<span class="invoice-label">INVOICE</span>
<span><strong>File:</strong> ${filename}</span>
<span><strong>Exported:</strong> ${exportedAt}</span>
</div>
</div>
<div class="sheet-wrapper">${sheetHTML}</div>
<div class="pdf-footer">
<span>${APP_NAME} — Official Document</span>
<span>Generated on ${exportedAt}</span>
</div>
</body>
</html>`;
};

/**
* Prints via a hidden iframe — avoids window.open popup blocker entirely.
*/
const printViaIframe = (html: string): void => {
const existing = document.getElementById("__invoice_print_frame__");
if (existing) existing.remove();

const iframe = document.createElement("iframe");
iframe.id = "__invoice_print_frame__";
iframe.style.cssText =
"position:fixed;top:0;left:0;width:0;height:0;border:none;opacity:0;pointer-events:none;";

document.body.appendChild(iframe);

const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
if (!iframeDoc) {
iframe.remove();
throw new Error("Could not access iframe document");
}

iframeDoc.open();
iframeDoc.write(html);
iframeDoc.close();

iframe.onload = () => {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
setTimeout(() => iframe.remove(), 2000);
};
};

export const useInvoicePDF = () => {
const exportAsPDF = (filename: string = "Invoice"): void => {
const html = buildPDFHTML(filename);
printViaIframe(html);
};

const downloadHTML = (filename: string = "Invoice"): void => {
const html = buildPDFHTML(filename);
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${filename.replace(/\s+/g, "_")}_invoice.html`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
};

return { exportAsPDF, downloadHTML };
};