Add policy download pdf file

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-05-04 11:22:33 -07:00
parent e5636b2c5d
commit 93112f89eb
2 changed files with 323 additions and 83 deletions

View File

@@ -1,4 +1,4 @@
import { Suspense, useEffect, useState, useCallback, ReactNode } from "react";
import { Suspense, useEffect, useState, useCallback, ReactNode, useRef } from "react";
import { useParams, Link, useNavigate } from "react-router";
import {
graphql,
@@ -6,6 +6,7 @@ import {
usePreloadedQuery,
useQueryLoader,
useMutation,
useFragment,
} from "react-relay";
import {
Clock,
@@ -43,9 +44,18 @@ import {
import { SignaturesModal } from "./SignaturesModal";
import { VersionHistoryModal } from "./VersionHistoryModal";
import rehypeRaw from "rehype-raw";
import { createRoot } from "react-dom/client";
import { policyVersionsFragment } from "./SignaturesModal";
import type { SignaturesModal_policyVersions$key } from "./__generated__/SignaturesModal_policyVersions.graphql";
const policyViewQuery = graphql`
query ShowPolicyViewQuery($policyId: ID!) {
query ShowPolicyViewQuery($policyId: ID!, $organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
name
}
}
node(id: $policyId) {
id
... on Policy {
@@ -131,6 +141,7 @@ function ShowPolicyContent({
);
const policy = data.node;
const { organizationId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const [queryRef2, loadQuery] =
@@ -139,6 +150,7 @@ function ShowPolicyContent({
const [isDeleting, setIsDeleting] = useState(false);
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
const [isSignaturesModalOpen, setIsSignaturesModalOpen] = useState(false);
const printContentRef = useRef<HTMLDivElement>(null);
const [publishDraft, isPublishInFlight] =
useMutation<ShowPolicyViewPublishMutation>(publishPolicyVersionMutation);
@@ -294,11 +306,179 @@ function ShowPolicyContent({
return format(date, "MMM d, yyyy");
};
// Get policy signatures data
const policyData = useFragment<SignaturesModal_policyVersions$key>(
policyVersionsFragment,
policy as unknown as SignaturesModal_policyVersions$key
);
// Handle PDF download
const handlePrintPDF = useCallback(() => {
// Create a new window for printing
const printWindow = window.open('', '_blank');
if (!printWindow) {
toast({
title: "Error",
description: "Unable to open print window. Please check your browser settings.",
variant: "destructive",
});
return;
}
// Get version nodes and signatures from the fragment data we have
const versionNodes = policyData?.policyVersions?.edges?.map(edge => edge.node) || [];
const currentVersion = versionNodes.find(v => v.version === latestVersionNode?.version);
const signatures = currentVersion?.signatures?.edges?.map(edge => edge.node) || [];
// Create temporary div to render and capture markdown
const tempDiv = document.createElement('div');
const root = createRoot(tempDiv);
// Render the markdown content to HTML
root.render(
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{latestVersionNode?.content || 'No content available'}
</ReactMarkdown>
);
// Give React a moment to render the content
setTimeout(() => {
const renderedMarkdown = tempDiv.innerHTML;
// Prepare signatures HTML
let signaturesHtml = '<p>No signatures yet</p>';
if (signatures.length > 0) {
signaturesHtml = `
<table style="width: 100%; border-collapse: collapse; margin-top: 10px;">
<thead>
<tr>
<th style="text-align: left; padding: 8px; border: 1px solid #ddd; background-color: #f2f2f2;">Name</th>
<th style="text-align: left; padding: 8px; border: 1px solid #ddd; background-color: #f2f2f2;">Status</th>
<th style="text-align: left; padding: 8px; border: 1px solid #ddd; background-color: #f2f2f2;">Date</th>
</tr>
</thead>
<tbody>
${signatures.map(sig => `
<tr>
<td style="padding: 8px; border: 1px solid #ddd;">${sig.signedBy?.fullName || 'Unknown'}</td>
<td style="padding: 8px; border: 1px solid #ddd;">${sig.state === 'SIGNED' ? 'Signed' : 'Pending'}</td>
<td style="padding: 8px; border: 1px solid #ddd;">${sig.signedAt ? formatDate(sig.signedAt) : 'Not yet signed'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
// Create the print document content with the policy document data
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${policy.title || 'Policy Document'}</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 40px; }
.header { margin-bottom: 30px; }
.content { margin-bottom: 40px; }
.footer { border-top: 1px solid #ccc; padding-top: 20px; }
.metadata { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 30px; background-color: #f9f9f9; padding: 15px; border-radius: 4px; }
.metadata-item { margin-bottom: 5px; }
.signature-title { margin-top: 20px; font-weight: bold; font-size: 1.2em; margin-bottom: 10px; }
h1 { font-size: 24px; margin-bottom: 20px; }
.company-name { font-size: 16px; color: #555; margin-bottom: 5px; }
/* Markdown styles */
.content h1 { font-size: 1.8em; margin-top: 1em; margin-bottom: 0.5em; }
.content h2 { font-size: 1.5em; margin-top: 1em; margin-bottom: 0.5em; }
.content h3 { font-size: 1.3em; margin-top: 1em; margin-bottom: 0.5em; }
.content h4 { font-size: 1.2em; margin-top: 1em; margin-bottom: 0.5em; }
.content h5 { font-size: 1.1em; margin-top: 1em; margin-bottom: 0.5em; }
.content h6 { font-size: 1em; margin-top: 1em; margin-bottom: 0.5em; }
.content p { margin-bottom: 1em; line-height: 1.6; }
.content ul, .content ol { margin-bottom: 1em; padding-left: 2em; }
.content li { margin-bottom: 0.5em; }
.content blockquote { border-left: 4px solid #ccc; padding-left: 1em; margin-left: 0; }
.content pre { background: #f4f4f4; padding: 1em; overflow-x: auto; }
.content code { background: #f4f4f4; padding: 0.2em 0.4em; }
.content table { border-collapse: collapse; width: 100%; margin-bottom: 1em; }
.content th, .content td { border: 1px solid #ccc; padding: 0.5em; }
.content th { background: #f4f4f4; }
@media print {
body { margin: 0; }
.page-break { page-break-after: always; }
/* Hide browser default headers and footers */
@page {
margin: 0;
size: auto;
}
html {
background-color: #FFFFFF;
margin: 0px;
}
body {
margin: 40px;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
}
</style>
</head>
<body>
<div class="header">
<h1>${policy.title || 'Policy Document'}</h1>
<div class="metadata">
<div class="metadata-item"><strong>Version:</strong> ${latestVersionNode?.version || 'N/A'}</div>
<div class="metadata-item"><strong>Status:</strong> ${latestVersionNode?.status === 'PUBLISHED' ? 'Published' : 'Draft'}</div>
<div class="metadata-item"><strong>Published Date:</strong> ${latestVersionNode?.status === 'PUBLISHED' ? formatDate(latestVersionNode.publishedAt || '') : 'Not yet published'}</div>
<div class="metadata-item"><strong>Owner:</strong> ${policy.owner?.fullName || 'Unknown'}</div>
<div class="metadata-item"><strong>Last Modified:</strong> ${formatDate(latestVersionNode?.updatedAt)}</div>
<div class="metadata-item"><strong>Company:</strong> ${data.organization.name}</div>
</div>
</div>
<div class="content">
${renderedMarkdown}
</div>
<div class="footer">
<div class="signature-title">Signatory</div>
${signaturesHtml}
</div>
</body>
</html>
`);
// Trigger print
printWindow.document.close();
printWindow.focus();
// Use setTimeout to give the browser time to load any resources
setTimeout(() => {
printWindow.print();
// Optional: close the window after printing
// printWindow.close();
}, 500);
// Clean up
root.unmount();
}, 100);
}, [policy, latestVersionNode, formatDate, toast, policyData, data.organization.name!]);
return (
<PageTemplate
title={policy.title!}
actions={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="rounded-full h-9 px-3 gap-1.5 shadow-sm border-[#022A0214] bg-white text-[#141E12]"
onClick={handlePrintPDF}
>
<Download className="h-4 w-4" />
<span className="font-medium">Download PDF</span>
</Button>
<Button
variant="outline"
size="sm"
@@ -473,11 +653,11 @@ function ShowPolicyContent({
export default function ShowPolicyView() {
const [queryRef, loadQuery] =
useQueryLoader<ShowPolicyViewQuery>(policyViewQuery);
const { policyId } = useParams();
const { policyId, organizationId } = useParams();
useEffect(() => {
loadQuery({ policyId: policyId! });
}, [loadQuery, policyId]);
loadQuery({ policyId: policyId!, organizationId: organizationId! });
}, [loadQuery, policyId, organizationId]);
if (!queryRef) {
return <ShowPolicyViewSkeleton />;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<351444982c4868267f30cc9fc8a2399a>>
* @generated SignedSource<<09afc0438feb0aa6a5a8a95f93d70951>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -12,6 +12,7 @@ import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type PolicyStatus = "DRAFT" | "PUBLISHED";
export type ShowPolicyViewQuery$variables = {
organizationId: string;
policyId: string;
};
export type ShowPolicyViewQuery$data = {
@@ -46,6 +47,9 @@ export type ShowPolicyViewQuery$data = {
readonly updatedAt?: string;
readonly " $fragmentSpreads": FragmentRefs<"SignaturesModal_policyVersions" | "VersionHistoryModal_policyVersions">;
};
readonly organization: {
readonly name?: string;
};
};
export type ShowPolicyViewQuery = {
response: ShowPolicyViewQuery$data;
@@ -53,70 +57,94 @@ export type ShowPolicyViewQuery = {
};
const node: ConcreteRequest = (function(){
var v0 = [
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "policyId"
},
v2 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "policyId"
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v1 = [
v3 = {
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
}
],
"type": "Organization",
"abstractKey": null
},
v4 = [
{
"kind": "Variable",
"name": "id",
"variableName": "policyId"
}
],
v2 = {
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
v6 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
v4 = {
v7 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
v5 = {
v8 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
v6 = {
v9 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
v7 = {
v10 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "currentPublishedVersion",
"storageKey": null
},
v8 = {
v11 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
v9 = {
v12 = {
"alias": null,
"args": null,
"concreteType": "People",
@@ -124,8 +152,8 @@ v9 = {
"name": "owner",
"plural": false,
"selections": [
(v2/*: any*/),
(v8/*: any*/),
(v5/*: any*/),
(v11/*: any*/),
{
"alias": null,
"args": null,
@@ -136,70 +164,70 @@ v9 = {
],
"storageKey": null
},
v10 = [
v13 = [
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
v11 = {
v14 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "version",
"storageKey": null
},
v12 = {
v15 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
v13 = {
v16 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "content",
"storageKey": null
},
v14 = {
v17 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "changelog",
"storageKey": null
},
v15 = {
v18 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "publishedAt",
"storageKey": null
},
v16 = {
v19 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v17 = [
(v8/*: any*/),
(v2/*: any*/)
v20 = [
(v11/*: any*/),
(v5/*: any*/)
],
v18 = {
v21 = {
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "publishedBy",
"plural": false,
"selections": (v17/*: any*/),
"selections": (v20/*: any*/),
"storageKey": null
},
v19 = [
v22 = [
{
"kind": "Literal",
"name": "first",
@@ -208,29 +236,44 @@ v19 = [
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "ShowPolicyViewQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"alias": "organization",
"args": (v2/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": (v4/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v5/*: any*/),
{
"kind": "InlineFragment",
"selections": [
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v6/*: any*/),
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
(v12/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
@@ -243,7 +286,7 @@ return {
},
{
"alias": "latestVersion",
"args": (v10/*: any*/),
"args": (v13/*: any*/),
"concreteType": "PolicyVersionConnection",
"kind": "LinkedField",
"name": "versions",
@@ -265,12 +308,12 @@ return {
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
(v13/*: any*/),
(v5/*: any*/),
(v14/*: any*/),
(v15/*: any*/),
(v16/*: any*/),
(v17/*: any*/),
(v18/*: any*/),
{
"alias": null,
"args": null,
@@ -279,12 +322,12 @@ return {
"name": "publishedBy",
"plural": false,
"selections": [
(v8/*: any*/)
(v11/*: any*/)
],
"storageKey": null
},
(v5/*: any*/),
(v6/*: any*/)
(v8/*: any*/),
(v9/*: any*/)
],
"storageKey": null
}
@@ -307,29 +350,46 @@ return {
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "ShowPolicyViewQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"alias": "organization",
"args": (v2/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v16/*: any*/),
(v2/*: any*/),
(v19/*: any*/),
(v3/*: any*/),
(v5/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": (v4/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v19/*: any*/),
(v5/*: any*/),
{
"kind": "InlineFragment",
"selections": [
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v6/*: any*/),
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
(v12/*: any*/),
{
"alias": "policyVersions",
"args": [
@@ -360,15 +420,15 @@ return {
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
(v5/*: any*/),
(v14/*: any*/),
(v15/*: any*/),
(v6/*: any*/),
(v18/*: any*/),
(v9/*: any*/),
(v21/*: any*/),
{
"alias": null,
"args": (v19/*: any*/),
"args": (v22/*: any*/),
"concreteType": "PolicyVersionSignatureConnection",
"kind": "LinkedField",
"name": "signatures",
@@ -390,7 +450,7 @@ return {
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v5/*: any*/),
{
"alias": null,
"args": null,
@@ -419,7 +479,7 @@ return {
"kind": "LinkedField",
"name": "signedBy",
"plural": false,
"selections": (v17/*: any*/),
"selections": (v20/*: any*/),
"storageKey": null
},
{
@@ -429,10 +489,10 @@ return {
"kind": "LinkedField",
"name": "requestedBy",
"plural": false,
"selections": (v17/*: any*/),
"selections": (v20/*: any*/),
"storageKey": null
},
(v16/*: any*/)
(v19/*: any*/)
],
"storageKey": null
},
@@ -476,7 +536,7 @@ return {
},
{
"alias": null,
"args": (v19/*: any*/),
"args": (v22/*: any*/),
"filters": null,
"handle": "connection",
"key": "SignaturesModal_policyVersions_signatures",
@@ -522,14 +582,14 @@ return {
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
(v13/*: any*/),
(v5/*: any*/),
(v14/*: any*/),
(v15/*: any*/),
(v6/*: any*/),
(v18/*: any*/)
(v16/*: any*/),
(v17/*: any*/),
(v18/*: any*/),
(v9/*: any*/),
(v21/*: any*/)
],
"storageKey": null
}
@@ -541,7 +601,7 @@ return {
},
{
"alias": "latestVersion",
"args": (v10/*: any*/),
"args": (v13/*: any*/),
"concreteType": "PolicyVersionConnection",
"kind": "LinkedField",
"name": "versions",
@@ -563,15 +623,15 @@ return {
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
(v13/*: any*/),
(v5/*: any*/),
(v14/*: any*/),
(v15/*: any*/),
(v16/*: any*/),
(v17/*: any*/),
(v18/*: any*/),
(v5/*: any*/),
(v6/*: any*/)
(v21/*: any*/),
(v8/*: any*/),
(v9/*: any*/)
],
"storageKey": null
}
@@ -591,16 +651,16 @@ return {
]
},
"params": {
"cacheID": "751e6cc23218ca2da7b5bf44d3eef59c",
"cacheID": "643b6f01d617d7f7fa6995afe4960c3c",
"id": null,
"metadata": {},
"name": "ShowPolicyViewQuery",
"operationKind": "query",
"text": "query ShowPolicyViewQuery(\n $policyId: ID!\n) {\n node(id: $policyId) {\n __typename\n id\n ... on Policy {\n title\n description\n createdAt\n updatedAt\n currentPublishedVersion\n owner {\n id\n fullName\n primaryEmailAddress\n }\n ...SignaturesModal_policyVersions\n ...VersionHistoryModal_policyVersions\n latestVersion: versions(first: 1) {\n edges {\n node {\n id\n version\n status\n content\n changelog\n publishedAt\n publishedBy {\n fullName\n id\n }\n createdAt\n updatedAt\n }\n }\n }\n }\n }\n}\n\nfragment SignaturesModal_policyVersions on Policy {\n title\n policyVersions: versions(first: 10) {\n edges {\n node {\n id\n version\n status\n publishedAt\n updatedAt\n publishedBy {\n fullName\n id\n }\n signatures(first: 100) {\n edges {\n node {\n id\n state\n signedAt\n requestedAt\n signedBy {\n fullName\n id\n }\n requestedBy {\n fullName\n id\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n }\n}\n\nfragment VersionHistoryModal_policyVersions on Policy {\n title\n owner {\n fullName\n id\n }\n versionHistory: versions(first: 20) {\n edges {\n node {\n id\n version\n status\n content\n changelog\n publishedAt\n updatedAt\n publishedBy {\n fullName\n id\n }\n }\n }\n }\n}\n"
"text": "query ShowPolicyViewQuery(\n $policyId: ID!\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n name\n }\n id\n }\n node(id: $policyId) {\n __typename\n id\n ... on Policy {\n title\n description\n createdAt\n updatedAt\n currentPublishedVersion\n owner {\n id\n fullName\n primaryEmailAddress\n }\n ...SignaturesModal_policyVersions\n ...VersionHistoryModal_policyVersions\n latestVersion: versions(first: 1) {\n edges {\n node {\n id\n version\n status\n content\n changelog\n publishedAt\n publishedBy {\n fullName\n id\n }\n createdAt\n updatedAt\n }\n }\n }\n }\n }\n}\n\nfragment SignaturesModal_policyVersions on Policy {\n title\n policyVersions: versions(first: 10) {\n edges {\n node {\n id\n version\n status\n publishedAt\n updatedAt\n publishedBy {\n fullName\n id\n }\n signatures(first: 100) {\n edges {\n node {\n id\n state\n signedAt\n requestedAt\n signedBy {\n fullName\n id\n }\n requestedBy {\n fullName\n id\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n }\n}\n\nfragment VersionHistoryModal_policyVersions on Policy {\n title\n owner {\n fullName\n id\n }\n versionHistory: versions(first: 20) {\n edges {\n node {\n id\n version\n status\n content\n changelog\n publishedAt\n updatedAt\n publishedBy {\n fullName\n id\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "58adb8690071648aecb252b2f807ea7d";
(node as any).hash = "56b534c0a468ccee10939c7d6c731bfd";
export default node;