Add document signing on the new ui
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
15
apps/console2/src/components/documentSigning/ProgressBar.tsx
Normal file
15
apps/console2/src/components/documentSigning/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
92
apps/console2/src/hooks/graph/__generated__/DocumentGraphSendSigningNotificationsMutation.graphql.ts
generated
Normal file
92
apps/console2/src/hooks/graph/__generated__/DocumentGraphSendSigningNotificationsMutation.graphql.ts
generated
Normal 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;
|
||||
264
apps/console2/src/pages/DocumentSigningRequestsPage.tsx
Normal file
264
apps/console2/src/pages/DocumentSigningRequestsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -72,6 +72,10 @@ const routes = [
|
||||
() => import("./pages/organizations/NewOrganizationPage")
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "documents/signing-requests",
|
||||
Component: lazy(() => import("./pages/DocumentSigningRequestsPage.tsx")),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user