diff --git a/apps/console/src/components/documentSigning/ProgressBar.tsx b/apps/console/src/components/documentSigning/ProgressBar.tsx deleted file mode 100644 index a9253b375..000000000 --- a/apps/console/src/components/documentSigning/ProgressBar.tsx +++ /dev/null @@ -1,15 +0,0 @@ -interface ProgressBarProps { - value: number; - className?: string; -} - -export function ProgressBar({ value, className }: ProgressBarProps) { - return ( -
-
-
- ); -} diff --git a/apps/console/src/components/documents/PDFPreview.tsx b/apps/console/src/components/documents/PDFPreview.tsx new file mode 100644 index 000000000..8e378115c --- /dev/null +++ b/apps/console/src/components/documents/PDFPreview.tsx @@ -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["ref"] = useRef(null); + const wrapperRef = useRef(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 ( +
+ {/* Custom Zoom Controls */} + + + {/* PDF Document */} +
+ + {numPages === 0 && } + {times(numPages, (index) => ( + + ))} + +
+
+ ); +} diff --git a/apps/console/src/pages/DocumentSigningRequestsPage.tsx b/apps/console/src/pages/DocumentSigningRequestsPage.tsx index 27bb75b3d..2da7da278 100644 --- a/apps/console/src/pages/DocumentSigningRequestsPage.tsx +++ b/apps/console/src/pages/DocumentSigningRequestsPage.tsx @@ -4,12 +4,16 @@ import { useTranslate } from "@probo/i18n"; import { Button, Card, - Markdown, - IconCheckmark1, + Logo, + Spinner, IconCircleProgress, + IconCircleCheck, } from "@probo/ui"; -import { ProgressBar } from "../components/documentSigning/ProgressBar"; +import { PDFPreview } from "../components/documents/PDFPreview"; import { buildEndpoint } from "/providers/RelayProviders"; +import { useWindowSize } from "usehooks-ts"; +import clsx from "clsx"; +import { sprintf } from "@probo/helpers"; type Document = { document_version_id: string; @@ -20,8 +24,7 @@ type Document = { type DocumentSigningResponse = { documents: Document[]; - requesterName: string; - requesterOrganization: string; + organizationName: string; }; export default function DocumentSigningRequestsPage() { @@ -30,10 +33,23 @@ export default function DocumentSigningRequestsPage() { const token = searchParams.get("token"); const [loading, setLoading] = useState(true); + const [signing, setSigning] = useState(false); const [error, setError] = useState(null); const [signingData, setSigningData] = useState(null); 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(() => { if (!token) { @@ -68,10 +84,15 @@ export default function DocumentSigningRequestsPage() { signed: false, })); + // Extract organization name from the first document + const organizationName = + documents.length > 0 + ? (documents[0] as any).organization_name || "Organization" + : "Organization"; + setSigningData({ documents: enhancedDocuments, - requesterName: "Requester", - requesterOrganization: "Organization", + organizationName, }); } catch (err) { setError( @@ -90,6 +111,7 @@ export default function DocumentSigningRequestsPage() { const docToSign = signingData.documents[currentDocIndex]; + setSigning(true); try { const response = await fetch( buildEndpoint( @@ -119,6 +141,9 @@ export default function DocumentSigningRequestsPage() { documents: updatedDocs, }); + // Collapse the document list when signing + setShowAllDocuments(false); + if (currentDocIndex < updatedDocs.length - 1) { setCurrentDocIndex(currentDocIndex + 1); } @@ -126,6 +151,8 @@ export default function DocumentSigningRequestsPage() { setError( 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; }; - const getProgressPercentage = () => { - if (!signingData || signingData.documents.length === 0) return 0; - return (getSignedCount() / signingData.documents.length) * 100; - }; - if (loading) { return ( <> @@ -211,74 +233,217 @@ export default function DocumentSigningRequestsPage() { const isLastDocument = currentDocIndex === signingData.documents.length - 1; 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 ( <> {__("Document Signing")} -
-
-

- {__("Document Signing Request")} -

-

- {__("From")} {signingData.requesterName} {__("at")}{" "} - {signingData.requesterOrganization} -

- -
-
- - {getSignedCount()} {__("of")} {signingData.documents.length}{" "} - {__("documents signed")} - - - {Math.round(getProgressPercentage())}% - -
- -
-
- - -
-
-

{currentDoc.title}

-

- {__("Document")} {currentDocIndex + 1} {__("of")}{" "} - {signingData.documents.length} +

+
+ +
+
+
+

+ {sprintf(__("%s requests your signature"), signingData.organizationName)} +

+ {allSigned ? ( +

+ {__( + "You have successfully signed all documents. You can now close this page." + )}

-
+ ) : ( + <> +

+ {__("Please review and sign the following documents:")} +

+ +
+ {(() => { + const renderDocumentItem = (doc: Document, index: number) => ( +
+
+ {doc.signed ? ( + + ) : ( + + {index + 1} + + )} +
+
+

+ {doc.title} +

+
+
+ + {doc.signed + ? __("Signed") + : index === currentDocIndex + ? __("In review") + : __("Waiting signature")} + +
+
+ ); -
- -
+ const totalDocs = signingData.documents.length; -
- {currentDoc.signed ? ( -
-
- - {__("Signed")} + if (totalDocs <= 4) { + return signingData.documents.map((doc, index) => + renderDocumentItem(doc, index) + ); + } + + if (showAllDocuments) { + return ( + <> + {signingData.documents.map((doc, index) => + renderDocumentItem(doc, index) + )} + + + ); + } + + // 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 && ( + + )} + + {/* Current document (if not first) */} + {!currentIsFirst && renderDocumentItem(currentDoc, currentDocIndex)} + + {/* Show more button for documents AFTER current (upcoming documents) */} + {hiddenAfterCurrent > 0 && ( + + )} + + ); + })()}
- {!isLastDocument && ( - + +

+ {__( + "Please review the document carefully before signing." )} -

- ) : ( - + )} + {!currentDoc.signed && !allSigned && ( + <> + +

+ {__( + "By clicking 'I acknowledge and agree', your digital signature will be recorded." + )} +

+ + )} + {currentDoc.signed && !isLastDocument && ( + + )} + - {__("All documents have been signed")} -
- )} -
+ > + Powered by +
- + {isDesktop && ( +
+ {pdfUrl && } +
+ )} +
); diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 4aab33a74..815402ab3 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -377,15 +377,24 @@ func (s *DocumentService) ListSigningRequests( SELECT p.title, pv.content, - pv.id AS document_version_id + pv.id AS document_version_id, + o.name AS organization_name FROM documents p 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 organizations o ON o.id = p.organization_id WHERE p.tenant_id = $1 AND pvs.signed_by = $2 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 diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 7deb3905c..48b769313 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -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( "/documents/signing-requests/{document_version_id}/sign", func(w http.ResponseWriter, r *http.Request) {