Make UI of signature page better
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -1,15 +0,0 @@
|
|||||||
interface ProgressBarProps {
|
|
||||||
value: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProgressBar({ value, className }: ProgressBarProps) {
|
|
||||||
return (
|
|
||||||
<div className={`w-full bg-gray-200 rounded-full h-2 ${className}`}>
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
130
apps/console/src/components/documents/PDFPreview.tsx
Normal file
130
apps/console/src/components/documents/PDFPreview.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { Document, Page, pdfjs } from "react-pdf";
|
||||||
|
import "react-pdf/dist/Page/TextLayer.css";
|
||||||
|
import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||||
|
import { type ComponentProps, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
IconArrowInbox,
|
||||||
|
IconChevronLeft,
|
||||||
|
IconChevronRight,
|
||||||
|
IconPlusLarge,
|
||||||
|
Spinner,
|
||||||
|
} from "@probo/ui";
|
||||||
|
import { times } from "@probo/helpers";
|
||||||
|
import { IconMinusLarge } from "@probo/ui/src/Atoms/Icons/IconMinusLarge.tsx";
|
||||||
|
|
||||||
|
// Worker for PDF.js
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
||||||
|
|
||||||
|
const btnClass =
|
||||||
|
"size-8 grid place-items-center hover:bg-secondary-hover cursor-pointer rounded-sm disabled:opacity-30 transition-all";
|
||||||
|
|
||||||
|
export function PDFPreview({ src, name }: { src: string; name?: string }) {
|
||||||
|
const [numPages, setNumPages] = useState(0);
|
||||||
|
const [scale, setScale] = useState(1.0);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const documentRef: ComponentProps<typeof Document>["ref"] = useRef(null);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const onDocumentLoadSuccess: ComponentProps<
|
||||||
|
typeof Document
|
||||||
|
>["onLoadSuccess"] = (document) => {
|
||||||
|
setNumPages(document.numPages);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomFactor = (factor: number) => () => {
|
||||||
|
setScale(scale * factor);
|
||||||
|
};
|
||||||
|
|
||||||
|
const movePage = (direction: 1 | -1) => () => {
|
||||||
|
if (currentPage === 1 && direction === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newPage = currentPage + direction;
|
||||||
|
const page = documentRef.current?.pages.current[newPage - 1];
|
||||||
|
if (!page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
page.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
inline: "center",
|
||||||
|
});
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCurrentPage = () => {
|
||||||
|
if (!wrapperRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pages = documentRef.current?.pages.current;
|
||||||
|
if (!pages?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parentRect = wrapperRef.current.getBoundingClientRect();
|
||||||
|
const parentMiddleY = parentRect.top + parentRect.height / 2;
|
||||||
|
for (let i = 0; i < pages.length; i++) {
|
||||||
|
const childRect = pages[i].getBoundingClientRect();
|
||||||
|
if (childRect.top <= parentMiddleY && childRect.bottom >= parentMiddleY) {
|
||||||
|
return setCurrentPage(i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-rows-[max-content_1fr] h-full bg-subtle">
|
||||||
|
{/* Custom Zoom Controls */}
|
||||||
|
<nav className="flex-none flex items-center gap-2 bg-level-1 py-3 text-sm pl-4 pr-3 text-txt-primary">
|
||||||
|
<div>{name}</div>
|
||||||
|
<div className="mx-auto flex gap-1 items-center">
|
||||||
|
<button
|
||||||
|
onClick={movePage(-1)}
|
||||||
|
className={btnClass}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<IconChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
{currentPage} / {numPages}
|
||||||
|
</div>
|
||||||
|
<button onClick={movePage(1)} className={btnClass}>
|
||||||
|
<IconChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={zoomFactor(0.8)} className={btnClass}>
|
||||||
|
<IconMinusLarge size={16} />
|
||||||
|
</button>
|
||||||
|
<button onClick={zoomFactor(1.2)} className={btnClass}>
|
||||||
|
<IconPlusLarge size={16} />
|
||||||
|
</button>
|
||||||
|
<button onClick={zoomFactor(1.2)} className={btnClass}>
|
||||||
|
<IconArrowInbox size={16} />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* PDF Document */}
|
||||||
|
<div
|
||||||
|
className="overflow-auto scroll-p-6"
|
||||||
|
onScrollEnd={resolveCurrentPage}
|
||||||
|
ref={wrapperRef}
|
||||||
|
>
|
||||||
|
<Document
|
||||||
|
file={src}
|
||||||
|
onLoadSuccess={onDocumentLoadSuccess}
|
||||||
|
className="flex flex-col gap-4 py-10"
|
||||||
|
ref={documentRef}
|
||||||
|
>
|
||||||
|
{numPages === 0 && <Spinner className="mx-auto" />}
|
||||||
|
{times(numPages, (index) => (
|
||||||
|
<Page
|
||||||
|
className="w-max h-max mx-auto shadow-mid"
|
||||||
|
key={index.toString()}
|
||||||
|
pageNumber={index + 1}
|
||||||
|
scale={scale} // Apply zoom via scale prop
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Document>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,12 +4,16 @@ import { useTranslate } from "@probo/i18n";
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Markdown,
|
Logo,
|
||||||
IconCheckmark1,
|
Spinner,
|
||||||
IconCircleProgress,
|
IconCircleProgress,
|
||||||
|
IconCircleCheck,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { ProgressBar } from "../components/documentSigning/ProgressBar";
|
import { PDFPreview } from "../components/documents/PDFPreview";
|
||||||
import { buildEndpoint } from "/providers/RelayProviders";
|
import { buildEndpoint } from "/providers/RelayProviders";
|
||||||
|
import { useWindowSize } from "usehooks-ts";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { sprintf } from "@probo/helpers";
|
||||||
|
|
||||||
type Document = {
|
type Document = {
|
||||||
document_version_id: string;
|
document_version_id: string;
|
||||||
@@ -20,8 +24,7 @@ type Document = {
|
|||||||
|
|
||||||
type DocumentSigningResponse = {
|
type DocumentSigningResponse = {
|
||||||
documents: Document[];
|
documents: Document[];
|
||||||
requesterName: string;
|
organizationName: string;
|
||||||
requesterOrganization: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentSigningRequestsPage() {
|
export default function DocumentSigningRequestsPage() {
|
||||||
@@ -30,10 +33,23 @@ export default function DocumentSigningRequestsPage() {
|
|||||||
const token = searchParams.get("token");
|
const token = searchParams.get("token");
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [signing, setSigning] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [signingData, setSigningData] =
|
const [signingData, setSigningData] =
|
||||||
useState<DocumentSigningResponse | null>(null);
|
useState<DocumentSigningResponse | null>(null);
|
||||||
const [currentDocIndex, setCurrentDocIndex] = useState(0);
|
const [currentDocIndex, setCurrentDocIndex] = useState(0);
|
||||||
|
const [showAllDocuments, setShowAllDocuments] = useState(false);
|
||||||
|
|
||||||
|
const { width } = useWindowSize();
|
||||||
|
const isMobile = width < 1100;
|
||||||
|
const isDesktop = !isMobile;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.setProperty("overflow", "hidden");
|
||||||
|
return () => {
|
||||||
|
document.body.style.removeProperty("overflow");
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -68,10 +84,15 @@ export default function DocumentSigningRequestsPage() {
|
|||||||
signed: false,
|
signed: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Extract organization name from the first document
|
||||||
|
const organizationName =
|
||||||
|
documents.length > 0
|
||||||
|
? (documents[0] as any).organization_name || "Organization"
|
||||||
|
: "Organization";
|
||||||
|
|
||||||
setSigningData({
|
setSigningData({
|
||||||
documents: enhancedDocuments,
|
documents: enhancedDocuments,
|
||||||
requesterName: "Requester",
|
organizationName,
|
||||||
requesterOrganization: "Organization",
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@@ -90,6 +111,7 @@ export default function DocumentSigningRequestsPage() {
|
|||||||
|
|
||||||
const docToSign = signingData.documents[currentDocIndex];
|
const docToSign = signingData.documents[currentDocIndex];
|
||||||
|
|
||||||
|
setSigning(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
buildEndpoint(
|
buildEndpoint(
|
||||||
@@ -119,6 +141,9 @@ export default function DocumentSigningRequestsPage() {
|
|||||||
documents: updatedDocs,
|
documents: updatedDocs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Collapse the document list when signing
|
||||||
|
setShowAllDocuments(false);
|
||||||
|
|
||||||
if (currentDocIndex < updatedDocs.length - 1) {
|
if (currentDocIndex < updatedDocs.length - 1) {
|
||||||
setCurrentDocIndex(currentDocIndex + 1);
|
setCurrentDocIndex(currentDocIndex + 1);
|
||||||
}
|
}
|
||||||
@@ -126,6 +151,8 @@ export default function DocumentSigningRequestsPage() {
|
|||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err.message : __("Failed to sign document")
|
err instanceof Error ? err.message : __("Failed to sign document")
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
setSigning(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,11 +167,6 @@ export default function DocumentSigningRequestsPage() {
|
|||||||
return signingData.documents.filter((doc) => doc.signed).length;
|
return signingData.documents.filter((doc) => doc.signed).length;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProgressPercentage = () => {
|
|
||||||
if (!signingData || signingData.documents.length === 0) return 0;
|
|
||||||
return (getSignedCount() / signingData.documents.length) * 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -211,74 +233,217 @@ export default function DocumentSigningRequestsPage() {
|
|||||||
const isLastDocument = currentDocIndex === signingData.documents.length - 1;
|
const isLastDocument = currentDocIndex === signingData.documents.length - 1;
|
||||||
const allSigned = getSignedCount() === signingData.documents.length;
|
const allSigned = getSignedCount() === signingData.documents.length;
|
||||||
|
|
||||||
|
// Build PDF URL with watermark
|
||||||
|
const pdfUrl = token
|
||||||
|
? `${buildEndpoint(
|
||||||
|
`/api/console/v1/documents/signing-requests/${currentDoc.document_version_id}/pdf`
|
||||||
|
)}?token=${encodeURIComponent(token)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>{__("Document Signing")}</title>
|
<title>{__("Document Signing")}</title>
|
||||||
<div className="container mx-auto py-10 space-y-6">
|
<div className="fixed inset-0 bg-level-2 z-100 flex flex-col lg:h-screen">
|
||||||
<div className="space-y-4">
|
<header className="flex items-center h-12 justify-between border-b border-border-solid px-4 flex-none">
|
||||||
<h1 className="text-3xl font-bold">
|
<Logo />
|
||||||
{__("Document Signing Request")}
|
</header>
|
||||||
</h1>
|
<div className="grid lg:grid-cols-2 min-h-0 h-full">
|
||||||
<p className="text-txt-tertiary">
|
<div className="max-w-[440px] mx-auto py-20">
|
||||||
{__("From")} {signingData.requesterName} {__("at")}{" "}
|
<h1 className="text-2xl font-semibold mb-6">
|
||||||
{signingData.requesterOrganization}
|
{sprintf(__("%s requests your signature"), signingData.organizationName)}
|
||||||
</p>
|
</h1>
|
||||||
|
{allSigned ? (
|
||||||
<div className="space-y-2">
|
<p className="text-txt-secondary text-base">
|
||||||
<div className="flex items-center justify-between">
|
{__(
|
||||||
<span className="text-sm text-txt-tertiary">
|
"You have successfully signed all documents. You can now close this page."
|
||||||
{getSignedCount()} {__("of")} {signingData.documents.length}{" "}
|
)}
|
||||||
{__("documents signed")}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{Math.round(getProgressPercentage())}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar value={getProgressPercentage()} className="h-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card padded>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold">{currentDoc.title}</h2>
|
|
||||||
<p className="text-txt-tertiary">
|
|
||||||
{__("Document")} {currentDocIndex + 1} {__("of")}{" "}
|
|
||||||
{signingData.documents.length}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-txt-secondary text-base mb-4">
|
||||||
|
{__("Please review and sign the following documents:")}
|
||||||
|
</p>
|
||||||
|
<Card className="mb-6 overflow-hidden">
|
||||||
|
<div className="divide-y divide-border-solid">
|
||||||
|
{(() => {
|
||||||
|
const renderDocumentItem = (doc: Document, index: number) => (
|
||||||
|
<div
|
||||||
|
key={doc.document_version_id}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-3 py-3 px-4 transition-colors",
|
||||||
|
index === currentDocIndex
|
||||||
|
? "bg-blue-50 border-l-4 border-blue-500"
|
||||||
|
: "bg-transparent hover:bg-level-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-level-2 flex-shrink-0">
|
||||||
|
{doc.signed ? (
|
||||||
|
<IconCircleCheck
|
||||||
|
size={20}
|
||||||
|
className="text-txt-success"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-semibold text-txt-tertiary">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
"text-sm font-medium truncate",
|
||||||
|
doc.signed
|
||||||
|
? "text-txt-tertiary"
|
||||||
|
: "text-txt-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{doc.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium",
|
||||||
|
doc.signed
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: index === currentDocIndex
|
||||||
|
? "bg-blue-100 text-blue-800"
|
||||||
|
: "bg-gray-100 text-gray-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{doc.signed
|
||||||
|
? __("Signed")
|
||||||
|
: index === currentDocIndex
|
||||||
|
? __("In review")
|
||||||
|
: __("Waiting signature")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
<div className="border rounded-md p-4 min-h-[400px] bg-bg-tertiary">
|
const totalDocs = signingData.documents.length;
|
||||||
<Markdown content={currentDoc.content} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
if (totalDocs <= 4) {
|
||||||
{currentDoc.signed ? (
|
return signingData.documents.map((doc, index) =>
|
||||||
<div className="flex items-center gap-4">
|
renderDocumentItem(doc, index)
|
||||||
<div className="flex items-center gap-2 text-green-600 font-medium">
|
);
|
||||||
<IconCheckmark1 size={16} />
|
}
|
||||||
{__("Signed")}
|
|
||||||
|
if (showAllDocuments) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{signingData.documents.map((doc, index) =>
|
||||||
|
renderDocumentItem(doc, index)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAllDocuments(false)}
|
||||||
|
className="w-full py-3 px-4 text-sm text-txt-secondary hover:bg-level-1 transition-colors text-left flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-txt-tertiary">•••</span>
|
||||||
|
{__("Show less")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show current document in collapsed view with two "show more" buttons
|
||||||
|
const firstDoc = signingData.documents[0];
|
||||||
|
const currentIsFirst = currentDocIndex === 0;
|
||||||
|
const currentIsLast = currentDocIndex === totalDocs - 1;
|
||||||
|
|
||||||
|
// Calculate hidden docs before and after current
|
||||||
|
const hiddenBeforeCurrent = currentIsFirst ? 0 : currentDocIndex - 1;
|
||||||
|
const hiddenAfterCurrent = currentIsLast ? 0 : totalDocs - currentDocIndex - 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* First document */}
|
||||||
|
{renderDocumentItem(firstDoc, 0)}
|
||||||
|
|
||||||
|
{/* Show more button for documents BEFORE current (signed documents) */}
|
||||||
|
{hiddenBeforeCurrent > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAllDocuments(true)}
|
||||||
|
className="w-full py-3 px-4 text-sm text-txt-secondary hover:bg-level-1 transition-colors text-left flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-txt-tertiary">•••</span>
|
||||||
|
{sprintf(__("Show %s more documents"), hiddenBeforeCurrent)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current document (if not first) */}
|
||||||
|
{!currentIsFirst && renderDocumentItem(currentDoc, currentDocIndex)}
|
||||||
|
|
||||||
|
{/* Show more button for documents AFTER current (upcoming documents) */}
|
||||||
|
{hiddenAfterCurrent > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAllDocuments(true)}
|
||||||
|
className="w-full py-3 px-4 text-sm text-txt-secondary hover:bg-level-1 transition-colors text-left flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-txt-tertiary">•••</span>
|
||||||
|
{sprintf(__("Show %s more documents"), hiddenAfterCurrent)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{!isLastDocument && (
|
</Card>
|
||||||
<Button onClick={handleNextDocument}>
|
<p className="text-txt-secondary text-sm mb-6">
|
||||||
{__("Next Document")}
|
{__(
|
||||||
</Button>
|
"Please review the document carefully before signing."
|
||||||
)}
|
)}
|
||||||
</div>
|
</p>
|
||||||
) : (
|
</>
|
||||||
<Button onClick={handleSignDocument}>
|
)}
|
||||||
{__("Sign Document")}
|
{isMobile && pdfUrl && (
|
||||||
|
<Button variant="secondary" asChild className="my-6 w-full">
|
||||||
|
<a target="_blank" rel="noopener noreferrer" href={pdfUrl}>
|
||||||
|
{__("View document")}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!currentDoc.signed && !allSigned && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={handleSignDocument}
|
||||||
|
className="h-10 w-full"
|
||||||
|
icon={signing ? Spinner : undefined}
|
||||||
|
disabled={signing}
|
||||||
|
>
|
||||||
|
{__("I acknowledge and agree")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p className="text-xs text-txt-tertiary mt-2">
|
||||||
|
{__(
|
||||||
|
"By clicking 'I acknowledge and agree', your digital signature will be recorded."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{currentDoc.signed && !isLastDocument && (
|
||||||
|
<Button
|
||||||
|
onClick={handleNextDocument}
|
||||||
|
className="h-10 w-full mt-4"
|
||||||
|
>
|
||||||
|
{__("Next Document")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href="https://www.getprobo.com/"
|
||||||
|
className={clsx(
|
||||||
|
"flex gap-1 text-sm font-medium text-txt-tertiary items-center w-max mx-auto",
|
||||||
|
isMobile ? "mt-15" : "mt-30"
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
{allSigned && (
|
Powered by <Logo withPicto className="h-6" />
|
||||||
<div className="text-green-600 font-medium">
|
</a>
|
||||||
{__("All documents have been signed")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
{isDesktop && (
|
||||||
|
<div className="bg-subtle h-full border-l border-border-solid min-h-0">
|
||||||
|
{pdfUrl && <PDFPreview src={pdfUrl} name={currentDoc.title} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -377,15 +377,24 @@ func (s *DocumentService) ListSigningRequests(
|
|||||||
SELECT
|
SELECT
|
||||||
p.title,
|
p.title,
|
||||||
pv.content,
|
pv.content,
|
||||||
pv.id AS document_version_id
|
pv.id AS document_version_id,
|
||||||
|
o.name AS organization_name
|
||||||
FROM
|
FROM
|
||||||
documents p
|
documents p
|
||||||
INNER JOIN document_versions pv ON pv.document_id = p.id
|
INNER JOIN document_versions pv ON pv.document_id = p.id
|
||||||
INNER JOIN document_version_signatures pvs ON pvs.document_version_id = pv.id
|
INNER JOIN document_version_signatures pvs ON pvs.document_version_id = pv.id
|
||||||
|
INNER JOIN organizations o ON o.id = p.organization_id
|
||||||
WHERE
|
WHERE
|
||||||
p.tenant_id = $1
|
p.tenant_id = $1
|
||||||
AND pvs.signed_by = $2
|
AND pvs.signed_by = $2
|
||||||
AND pvs.signed_at IS NULL
|
AND pvs.signed_at IS NULL
|
||||||
|
AND pv.status = 'PUBLISHED'
|
||||||
|
AND pv.version_number = (
|
||||||
|
SELECT MAX(pv2.version_number)
|
||||||
|
FROM document_versions pv2
|
||||||
|
WHERE pv2.document_id = pv.document_id
|
||||||
|
AND pv2.status = 'PUBLISHED'
|
||||||
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
var results []map[string]any
|
var results []map[string]any
|
||||||
|
|||||||
@@ -122,6 +122,54 @@ func NewMux(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
r.Get(
|
||||||
|
"/documents/signing-requests/{document_version_id}/pdf",
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "token is required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := statelesstoken.ValidateToken[probo.SigningRequestData](authCfg.CookieSecret, probo.TokenTypeSigningRequest, token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
documentVersionID, err := gid.ParseGID(chi.URLParam(r, "document_version_id"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid document version id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := proboSvc.WithTenant(data.Data.OrganizationID.TenantID())
|
||||||
|
|
||||||
|
// Get the people to get their email for watermark
|
||||||
|
people, err := svc.Peoples.Get(r.Context(), data.Data.PeopleID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to get user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate PDF with watermark
|
||||||
|
pdfData, err := svc.Documents.ExportPDF(r.Context(), documentVersionID, probo.ExportPDFOptions{
|
||||||
|
WithWatermark: true,
|
||||||
|
WatermarkEmail: &people.PrimaryEmailAddress,
|
||||||
|
WithSignatures: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
|
w.Header().Set("Content-Disposition", "inline; filename=\"document.pdf\"")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(pdfData)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
r.Post(
|
r.Post(
|
||||||
"/documents/signing-requests/{document_version_id}/sign",
|
"/documents/signing-requests/{document_version_id}/sign",
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
Reference in New Issue
Block a user