Add document signing on the new ui

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-06-12 23:22:36 -07:00
parent 187bc84365
commit 29a82b0645
7 changed files with 430 additions and 56 deletions

View File

@@ -0,0 +1,15 @@
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>
);
}

View File

@@ -2,6 +2,7 @@ import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "../useMutationWithToasts";
import type { DocumentGraphDeleteMutation } from "./__generated__/DocumentGraphDeleteMutation.graphql";
import type { DocumentGraphSendSigningNotificationsMutation } from "./__generated__/DocumentGraphSendSigningNotificationsMutation.graphql";
export const documentsQuery = graphql`
query DocumentGraphListQuery($organizationId: ID!) {
@@ -37,6 +38,28 @@ export function useDeleteDocumentMutation() {
);
}
const sendSigningNotificationsMutation = graphql`
mutation DocumentGraphSendSigningNotificationsMutation(
$input: SendSigningNotificationsInput!
) {
sendSigningNotifications(input: $input) {
success
}
}
`;
export function useSendSigningNotificationsMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<DocumentGraphSendSigningNotificationsMutation>(
sendSigningNotificationsMutation,
{
successMessage: __("Signing notifications sent successfully."),
errorMessage: __("Failed to send signing notifications. Please try again."),
}
);
}
export const documentNodeQuery = graphql`
query DocumentGraphNodeQuery($documentId: ID!) {
node(id: $documentId) {

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<eb81ead93d6ec8abc015415a48daaf08>>
* @generated SignedSource<<00b3c915ccd04831f9161442e27b635e>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -42,14 +42,6 @@ export type AssetGraphListQuery$data = {
};
}>;
};
readonly peoples?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly fullName: string;
readonly id: string;
};
}>;
};
};
};
export type AssetGraphListQuery = {
@@ -233,42 +225,7 @@ v15 = [
"name": "first",
"value": 100
}
],
v16 = {
"alias": null,
"args": (v15/*: any*/),
"concreteType": "PeopleConnection",
"kind": "LinkedField",
"name": "peoples",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PeopleEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v8/*: any*/)
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "peoples(first:100)"
};
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
@@ -343,8 +300,7 @@ return {
(v14/*: any*/)
],
"storageKey": null
},
(v16/*: any*/)
}
],
"type": "Organization",
"abstractKey": null
@@ -440,8 +396,7 @@ return {
"key": "AssetsPage_assets",
"kind": "LinkedHandle",
"name": "assets"
},
(v16/*: any*/)
}
],
"type": "Organization",
"abstractKey": null
@@ -453,7 +408,7 @@ return {
]
},
"params": {
"cacheID": "2b452fda67547e63fb04fd4c6ae3ec7e",
"cacheID": "b070f08982995825d2695f833f773f5e",
"id": null,
"metadata": {
"connection": [
@@ -470,11 +425,11 @@ return {
},
"name": "AssetGraphListQuery",
"operationKind": "query",
"text": "query AssetGraphListQuery(\n $organizationId: ID!\n) {\n node(id: $organizationId) {\n __typename\n ... on Organization {\n assets(first: 100) {\n edges {\n node {\n id\n name\n amount\n criticity\n assetType\n dataTypesStored\n owner {\n fullName\n id\n }\n vendors(first: 50) {\n edges {\n node {\n id\n name\n websiteUrl\n }\n }\n }\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n peoples(first: 100) {\n edges {\n node {\n id\n fullName\n }\n }\n }\n }\n id\n }\n}\n"
"text": "query AssetGraphListQuery(\n $organizationId: ID!\n) {\n node(id: $organizationId) {\n __typename\n ... on Organization {\n assets(first: 100) {\n edges {\n node {\n id\n name\n amount\n criticity\n assetType\n dataTypesStored\n owner {\n fullName\n id\n }\n vendors(first: 50) {\n edges {\n node {\n id\n name\n websiteUrl\n }\n }\n }\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "63f6a6df2bdbcd5539e28f892e54c686";
(node as any).hash = "79e2cf57067fabd4b124e6c0b47220dd";
export default node;

View File

@@ -0,0 +1,92 @@
/**
* @generated SignedSource<<242a8628b9203b02dccea5e311141ec4>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type SendSigningNotificationsInput = {
organizationId: string;
};
export type DocumentGraphSendSigningNotificationsMutation$variables = {
input: SendSigningNotificationsInput;
};
export type DocumentGraphSendSigningNotificationsMutation$data = {
readonly sendSigningNotifications: {
readonly success: boolean;
};
};
export type DocumentGraphSendSigningNotificationsMutation = {
response: DocumentGraphSendSigningNotificationsMutation$data;
variables: DocumentGraphSendSigningNotificationsMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "SendSigningNotificationsPayload",
"kind": "LinkedField",
"name": "sendSigningNotifications",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "DocumentGraphSendSigningNotificationsMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "DocumentGraphSendSigningNotificationsMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "111167f828fa6eaad3beb44c43e43f06",
"id": null,
"metadata": {},
"name": "DocumentGraphSendSigningNotificationsMutation",
"operationKind": "mutation",
"text": "mutation DocumentGraphSendSigningNotificationsMutation(\n $input: SendSigningNotificationsInput!\n) {\n sendSigningNotifications(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "eaf2fba7629e202537cfc7bd9b5c9586";
export default node;

View File

@@ -0,0 +1,264 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { useTranslate } from "@probo/i18n";
import {
Button,
Card,
Markdown,
IconCheckmark1,
IconCircleProgress
} from "@probo/ui";
import { ProgressBar } from "../components/documentSigning/ProgressBar";
type Document = {
document_version_id: string;
title: string;
content: string;
signed?: boolean;
};
type DocumentSigningResponse = {
documents: Document[];
requesterName: string;
requesterOrganization: string;
};
export default function DocumentSigningRequestsPage() {
const { __ } = useTranslate();
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [signingData, setSigningData] = useState<DocumentSigningResponse | null>(null);
const [currentDocIndex, setCurrentDocIndex] = useState(0);
useEffect(() => {
if (!token) {
setError(__("Missing signing token. Please check your URL and try again."));
setLoading(false);
return;
}
async function fetchDocuments() {
try {
const response = await fetch(
`${import.meta.env.VITE_API_URL}/api/console/v1/documents/signing-requests`,
{
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error(__("Failed to fetch signing documents"));
}
const documents: Document[] = await response.json();
const enhancedDocuments = documents.map((doc) => ({
...doc,
signed: false,
}));
setSigningData({
documents: enhancedDocuments,
requesterName: "Requester",
requesterOrganization: "Organization",
});
} catch (err) {
setError(
err instanceof Error ? err.message : __("An unknown error occurred"),
);
} finally {
setLoading(false);
}
}
fetchDocuments();
}, [token, __]);
const handleSignDocument = async () => {
if (!signingData || !token) return;
const docToSign = signingData.documents[currentDocIndex];
try {
const response = await fetch(
`${import.meta.env.VITE_API_URL}/api/console/v1/documents/signing-requests/${docToSign.document_version_id}/sign`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error(__("Failed to sign document"));
}
const updatedDocs = [...signingData.documents];
updatedDocs[currentDocIndex] = {
...updatedDocs[currentDocIndex],
signed: true,
};
setSigningData({
...signingData,
documents: updatedDocs,
});
if (currentDocIndex < updatedDocs.length - 1) {
setCurrentDocIndex(currentDocIndex + 1);
}
} catch (err) {
setError(err instanceof Error ? err.message : __("Failed to sign document"));
}
};
const handleNextDocument = () => {
if (signingData && currentDocIndex < signingData.documents.length - 1) {
setCurrentDocIndex(currentDocIndex + 1);
}
};
const getSignedCount = () => {
if (!signingData) return 0;
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 (
<>
<title>{__("Loading Signing Requests")}</title>
<div className="flex justify-center items-center min-h-screen">
<Card padded className="w-full max-w-3xl">
<div className="flex items-center gap-4">
<IconCircleProgress size={24} className="text-txt-accent" />
<div>
<h1 className="text-xl font-semibold">{__("Loading Signing Requests")}</h1>
<p className="text-txt-tertiary">
{__("Please wait while we fetch your documents...")}
</p>
</div>
</div>
</Card>
</div>
</>
);
}
if (error) {
return (
<>
<title>{__("Error")}</title>
<div className="flex justify-center items-center min-h-screen">
<Card padded className="w-full max-w-3xl">
<h1 className="text-xl font-semibold text-red-600 mb-2">{__("Error")}</h1>
<p className="text-txt-tertiary mb-4">{error}</p>
<Button onClick={() => window.location.reload()}>
{__("Try Again")}
</Button>
</Card>
</div>
</>
);
}
if (!signingData || signingData.documents.length === 0) {
return (
<>
<title>{__("No Documents to Sign")}</title>
<div className="flex justify-center items-center min-h-screen">
<Card padded className="w-full max-w-3xl">
<h1 className="text-xl font-semibold mb-2">{__("No Documents to Sign")}</h1>
<p className="text-txt-tertiary">
{__("There are no documents requiring your signature at this time.")}
</p>
</Card>
</div>
</>
);
}
const currentDoc = signingData.documents[currentDocIndex];
const isLastDocument = currentDocIndex === signingData.documents.length - 1;
const allSigned = getSignedCount() === signingData.documents.length;
return (
<>
<title>{__("Document Signing")}</title>
<div className="container mx-auto py-10 space-y-6">
<div className="space-y-4">
<h1 className="text-3xl font-bold">{__("Document Signing Request")}</h1>
<p className="text-txt-tertiary">
{__("From")} {signingData.requesterName} {__("at")} {signingData.requesterOrganization}
</p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-txt-tertiary">
{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>
</div>
<div className="border rounded-md p-4 min-h-[400px] bg-bg-tertiary">
<Markdown content={currentDoc.content} />
</div>
<div className="flex justify-between items-center">
{currentDoc.signed ? (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-green-600 font-medium">
<IconCheckmark1 size={16} />
{__("Signed")}
</div>
{!isLastDocument && (
<Button onClick={handleNextDocument}>
{__("Next Document")}
</Button>
)}
</div>
) : (
<Button onClick={handleSignDocument}>
{__("Sign Document")}
</Button>
)}
{allSigned && (
<div className="text-green-600 font-medium">
{__("All documents have been signed")}
</div>
)}
</div>
</div>
</Card>
</div>
</>
);
}

View File

@@ -15,6 +15,7 @@ import {
useConfirm,
ActionDropdown,
DropdownItem,
IconBell2,
} from "@probo/ui";
import {
useFragment,
@@ -26,6 +27,7 @@ import type { DocumentGraphListQuery } from "/hooks/graph/__generated__/Document
import {
documentsQuery,
useDeleteDocumentMutation,
useSendSigningNotificationsMutation,
} from "/hooks/graph/DocumentGraph";
import type { DocumentsPageListFragment$key } from "./__generated__/DocumentsPageListFragment.graphql";
import { usePageTitle } from "@probo/hooks";
@@ -65,18 +67,37 @@ export default function DocumentsPage(props: Props) {
const documents = data.documents.edges.map((edge) => edge.node);
const connectionId = data.documents.__id;
const [sendSigningNotifications] = useSendSigningNotificationsMutation();
usePageTitle(__("Documents"));
const handleSendSigningNotifications = () => {
sendSigningNotifications({
variables: {
input: { organizationId: organization.id },
},
});
};
return (
<div className="space-y-6">
<PageHeader
title={__("Documents")}
description={__("Manage your organization's documents")}
>
<CreateDocumentDialog
connection={connectionId}
trigger={<Button icon={IconPlusLarge}>{__("New document")}</Button>}
/>
<div className="flex gap-2">
<Button
icon={IconBell2}
variant="secondary"
onClick={handleSendSigningNotifications}
>
{__("Send signing notifications")}
</Button>
<CreateDocumentDialog
connection={connectionId}
trigger={<Button icon={IconPlusLarge}>{__("New document")}</Button>}
/>
</div>
</PageHeader>
<Table>
<Thead>

View File

@@ -72,6 +72,10 @@ const routes = [
() => import("./pages/organizations/NewOrganizationPage")
),
},
{
path: "documents/signing-requests",
Component: lazy(() => import("./pages/DocumentSigningRequestsPage.tsx")),
},
],
},
{