Add policy sign email notif

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-04-29 22:08:21 -07:00
parent ae2ca41d35
commit a2bec08d16
13 changed files with 954 additions and 18 deletions

View File

@@ -13,6 +13,7 @@ import AuthLayout from "./layouts/AuthLayout";
import { RelayEnvironment } from "./RelayEnvironment";
import { AuthenticationRoutes } from "./pages/authentication/Routes";
import { OrganizationsRoutes } from "./pages/organizations/Routes";
import SigningRequestsPage from "./pages/SigningRequestsPage";
posthog.init(process.env.POSTHOG_KEY!, {
api_host: process.env.POSTHOG_HOST,
@@ -53,6 +54,11 @@ function App() {
element={<OrganizationsRoutes />}
/>
<Route
path="policies/signing-requests"
element={<SigningRequestsPage />}
/>
<Route
path="*"
element={

View File

@@ -0,0 +1,235 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { Helmet } from "react-helmet-async";
import { buildEndpoint } from "../utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
type Document = {
id: string;
title: string;
content: string;
signed: boolean;
};
type SigningResponse = {
documents: Document[];
requesterName: string;
requesterOrganization: string;
};
export default function SigningRequestsPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [signingData, setSigningData] = useState<SigningResponse | null>(null);
const [currentDocIndex, setCurrentDocIndex] = useState(0);
// Fetch documents to sign using the token
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(buildEndpoint("/api/signing-requests"), {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Failed to fetch signing documents");
}
const data: SigningResponse = await response.json();
setSigningData(data);
} catch (err) {
setError(err instanceof Error ? err.message : "An unknown error occurred");
} finally {
setLoading(false);
}
}
fetchDocuments();
}, [token]);
// Handle document signing
const handleSignDocument = async () => {
if (!signingData || !token) return;
const docToSign = signingData.documents[currentDocIndex];
try {
const response = await fetch(buildEndpoint(`/api/signing-requests/${docToSign.id}/sign`), {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Failed to sign document");
}
// Update local state
const updatedDocs = [...signingData.documents];
updatedDocs[currentDocIndex] = {
...updatedDocs[currentDocIndex],
signed: true,
};
setSigningData({
...signingData,
documents: updatedDocs,
});
// Move to next document if available
if (currentDocIndex < updatedDocs.length - 1) {
setCurrentDocIndex(currentDocIndex + 1);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to sign document");
}
};
// Handle going to next document
const handleNextDocument = () => {
if (signingData && currentDocIndex < signingData.documents.length - 1) {
setCurrentDocIndex(currentDocIndex + 1);
}
};
// Calculate progress
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;
};
// Loading state
if (loading) {
return (
<div className="container mx-auto py-10 flex justify-center">
<Card className="w-full max-w-3xl">
<CardHeader>
<CardTitle>Loading Signing Requests</CardTitle>
<CardDescription>Please wait while we fetch your documents...</CardDescription>
</CardHeader>
</Card>
</div>
);
}
// Error state
if (error) {
return (
<div className="container mx-auto py-10 flex justify-center">
<Card className="w-full max-w-3xl">
<CardHeader>
<CardTitle>Error</CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardFooter>
<Button onClick={() => window.location.reload()}>Try Again</Button>
</CardFooter>
</Card>
</div>
);
}
// No documents or missing data
if (!signingData || signingData.documents.length === 0) {
return (
<div className="container mx-auto py-10 flex justify-center">
<Card className="w-full max-w-3xl">
<CardHeader>
<CardTitle>No Documents to Sign</CardTitle>
<CardDescription>There are no documents requiring your signature at this time.</CardDescription>
</CardHeader>
</Card>
</div>
);
}
// Current document
const currentDoc = signingData.documents[currentDocIndex];
const isLastDocument = currentDocIndex === signingData.documents.length - 1;
const allSigned = getSignedCount() === signingData.documents.length;
return (
<div className="container mx-auto py-10">
<Helmet>
<title>Document Signing - Probo</title>
</Helmet>
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2">Document Signing Request</h1>
<p className="text-muted-foreground">
From {signingData.requesterName} at {signingData.requesterOrganization}
</p>
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">
{getSignedCount()} of {signingData.documents.length} documents signed
</span>
<span className="text-sm font-medium">{Math.round(getProgressPercentage())}%</span>
</div>
<Progress value={getProgressPercentage()} className="h-2" />
</div>
</div>
<Card className="w-full">
<CardHeader>
<CardTitle>{currentDoc.title}</CardTitle>
<CardDescription>
Document {currentDocIndex + 1} of {signingData.documents.length}
</CardDescription>
</CardHeader>
<CardContent>
<div className="border rounded-md p-4 min-h-[400px] bg-muted/20">
<div dangerouslySetInnerHTML={{ __html: currentDoc.content }} />
</div>
</CardContent>
<CardFooter className="flex justify-between">
{currentDoc.signed ? (
<div className="flex items-center gap-2">
<div className="text-sm text-green-600 font-medium">✓ 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>
)}
</CardFooter>
</Card>
</div>
);
}

View File

@@ -29,6 +29,7 @@ import { format } from "date-fns";
import type { PolicyListViewQuery, PolicyListViewQuery$data } from "./__generated__/PolicyListViewQuery.graphql";
import type { PolicyListViewDeleteMutation } from "./__generated__/PolicyListViewDeleteMutation.graphql";
import type { PolicyListViewCreateMutation } from "./__generated__/PolicyListViewCreateMutation.graphql";
import type { PolicyListViewSendSigningNotificationsMutation } from "./__generated__/PolicyListViewSendSigningNotificationsMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { PolicyListViewSkeleton } from "./PolicyListPage";
import {
@@ -117,6 +118,13 @@ const createPolicyMutation = graphql`
}
`;
const sendSigningNotificationsMutation = graphql`
mutation PolicyListViewSendSigningNotificationsMutation($input: SendSigningNotificationsInput!) {
sendSigningNotifications(input: $input) {
success
}
}
`;
function PolicyTableRow({
policy,
organizationId,
@@ -360,7 +368,10 @@ function CreatePolicyModal({
className={`text-4xl leading-tight font-bold outline-none focus:outline-none ${!title ? 'text-gray-400' : 'text-black'}`}
contentEditable
suppressContentEditableWarning
onInput={(e) => setTitle(e.currentTarget.textContent || "")}
onInput={(e) => {
const newText = e.currentTarget.textContent || "";
setTitle(newText);
}}
style={{ WebkitTapHighlightColor: 'transparent' }}
onClick={(e) => {
if (!title) {
@@ -368,12 +379,22 @@ function CreatePolicyModal({
}
}}
onFocus={(e) => {
if (!title) {
if (e.currentTarget.textContent === "Enter policy title...") {
e.currentTarget.textContent = '';
}
}}
onBlur={(e) => {
if (!e.currentTarget.textContent?.trim()) {
e.currentTarget.textContent = "Enter policy title...";
setTitle("");
}
}}
ref={(el) => {
if (el && !el.textContent) {
el.textContent = title || "Enter policy title...";
}
}}
>
{title || "Enter policy title..."}
</h1>
</div>
<Textarea
@@ -452,6 +473,8 @@ function PolicyListViewContent({
const [createModalOpen, setCreateModalOpen] = useState(false);
const [sendSigningNotifications, _] = useMutation<PolicyListViewSendSigningNotificationsMutation>(sendSigningNotificationsMutation);
const handleOpenModal = () => {
setCreateModalOpen(true);
};
@@ -460,14 +483,29 @@ function PolicyListViewContent({
setCreateModalOpen(open);
};
const handleSendSigningNotifications = () => {
sendSigningNotifications({
variables: {
input: {
organizationId: organizationId!,
},
},
});
};
return (
<PageTemplate
title="Policies"
actions={
<Button onClick={handleOpenModal}>
<Plus className="mr-2 h-4 w-4" />
New policy
<div>
<Button onClick={handleOpenModal}>
<Plus className="mr-2 h-4 w-4" />
New policy
</Button>
<Button onClick={handleSendSigningNotifications}>
Send signing notifications
</Button>
</div>
}
>
{/* Policy table */}

View File

@@ -0,0 +1,92 @@
/**
* @generated SignedSource<<ec7d0ef29b8366117ddf75e75669f5fa>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type SendSigningNotificationsInput = {
organizationId: string;
};
export type PolicyListViewSendSigningNotificationsMutation$variables = {
input: SendSigningNotificationsInput;
};
export type PolicyListViewSendSigningNotificationsMutation$data = {
readonly sendSigningNotifications: {
readonly success: boolean;
};
};
export type PolicyListViewSendSigningNotificationsMutation = {
response: PolicyListViewSendSigningNotificationsMutation$data;
variables: PolicyListViewSendSigningNotificationsMutation$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": "PolicyListViewSendSigningNotificationsMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PolicyListViewSendSigningNotificationsMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "a6b25b9e4abf4243131b90b47f4e2ef4",
"id": null,
"metadata": {},
"name": "PolicyListViewSendSigningNotificationsMutation",
"operationKind": "mutation",
"text": "mutation PolicyListViewSendSigningNotificationsMutation(\n $input: SendSigningNotificationsInput!\n) {\n sendSigningNotifications(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "71b0a1e7d209ed71a9bb6552eaf95cee";
export default node;