Add policy sign email notif
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -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={
|
||||
|
||||
235
apps/console/src/pages/SigningRequestsPage.tsx
Normal file
235
apps/console/src/pages/SigningRequestsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user