mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Add signature management features for booking contracts
- Implement MySignatureCard component for viewing and updating saved signatures. - Enhance BookingContractPage to utilize saved signatures for contract signing. - Introduce hooks for fetching and saving user signatures. - Update ContractView interface to include saved signature details. - Create signatures service for API interactions related to user signatures.
This commit is contained in:
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { FileSignature, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import {
|
||||||
|
useMySignature,
|
||||||
|
useSaveSignature,
|
||||||
|
} from "@/hooks/useSavedSignature";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the signed-in customer view and update the reusable signature stored on
|
||||||
|
* their profile. The same signature is offered for approval when signing a
|
||||||
|
* booking contract.
|
||||||
|
*/
|
||||||
|
export function MySignatureCard() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { data: saved, isPending } = useMySignature();
|
||||||
|
const saveMutation = useSaveSignature();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [signerName, setSignerName] = useState("");
|
||||||
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const defaultName = user?.name?.en || user?.username || user?.email || "";
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
setSignerName(saved?.signerDisplayName ?? defaultName);
|
||||||
|
setSignatureData(null);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!signatureData || !signerName.trim()) return;
|
||||||
|
saveMutation.mutate(
|
||||||
|
{
|
||||||
|
signerDisplayName: signerName.trim(),
|
||||||
|
signatureImageBase64: signatureData,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setOpen(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileSignature className="size-5 text-primary" />
|
||||||
|
My signature
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Reused to approve and sign booking contracts.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{isPending ? (
|
||||||
|
<div className="flex h-36 items-center justify-center">
|
||||||
|
<Loader2 className="size-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : saved?.signatureImageUrl ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={saved.signatureImageUrl}
|
||||||
|
alt="My saved signature"
|
||||||
|
className="mx-auto h-36 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Saved as {saved.signerDisplayName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You have not saved a signature yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={openDialog}>
|
||||||
|
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Save your signature</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Draw your signature below. It will be stored on your profile for
|
||||||
|
future contracts.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profileSignerName">Full name</Label>
|
||||||
|
<Input
|
||||||
|
id="profileSignerName"
|
||||||
|
value={signerName}
|
||||||
|
onChange={(e) => setSignerName(e.target.value)}
|
||||||
|
placeholder="As shown on contracts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
saveMutation.isPending || !signatureData || !signerName.trim()
|
||||||
|
}
|
||||||
|
onClick={save}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Save signature"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts
Normal file
30
apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import {
|
||||||
|
signaturesService,
|
||||||
|
type SaveSignaturePayload,
|
||||||
|
} from "@/services/signatures.service";
|
||||||
|
|
||||||
|
const SAVED_SIGNATURE_KEY = ["me", "signature"] as const;
|
||||||
|
|
||||||
|
export function useMySignature() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: SAVED_SIGNATURE_KEY,
|
||||||
|
queryFn: () => signaturesService.getMySignature(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveSignature() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SaveSignaturePayload) =>
|
||||||
|
signaturesService.saveMySignature(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Signature saved");
|
||||||
|
void qc.invalidateQueries({ queryKey: SAVED_SIGNATURE_KEY });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to save signature"),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
|
import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { MySignatureCard } from "@/components/profile/MySignatureCard";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
|
||||||
|
|
||||||
function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
|
function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
|
||||||
@@ -175,6 +176,8 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<MySignatureCard />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export default function BookingContractPage() {
|
|||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
// When a saved signature exists we offer it for approval first; the customer
|
||||||
|
// can switch to drawing a fresh one.
|
||||||
|
const [drawNew, setDrawNew] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ["booking-contract-view", id],
|
queryKey: ["booking-contract-view", id],
|
||||||
@@ -32,6 +35,31 @@ export default function BookingContractPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const savedSignature = data?.savedSignature ?? null;
|
||||||
|
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||||
|
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||||
|
|
||||||
|
const openSign = () => {
|
||||||
|
// Prefill from the saved signature so the customer only has to approve it.
|
||||||
|
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||||
|
setSignatureData(null);
|
||||||
|
setDrawNew(false);
|
||||||
|
setSignOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSign = () => {
|
||||||
|
if (!signerName.trim()) return;
|
||||||
|
// Approve the saved signature, or submit the freshly drawn one.
|
||||||
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
|
if (!image) return;
|
||||||
|
signMutation.mutate({
|
||||||
|
role: "CUSTOMER",
|
||||||
|
signatureImageBase64: image,
|
||||||
|
signerDisplayName: signerName.trim(),
|
||||||
|
consentText: "I agree to the terms of this contract.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const signMutation = useMutation({
|
const signMutation = useMutation({
|
||||||
mutationFn: (payload: SignContractPayload) =>
|
mutationFn: (payload: SignContractPayload) =>
|
||||||
bookingsService.signContract(id!, payload),
|
bookingsService.signContract(id!, payload),
|
||||||
@@ -102,9 +130,9 @@ export default function BookingContractPage() {
|
|||||||
PDF
|
PDF
|
||||||
</Button>
|
</Button>
|
||||||
{data.canSignCustomer && (
|
{data.canSignCustomer && (
|
||||||
<Button size="sm" onClick={() => setSignOpen(true)}>
|
<Button size="sm" onClick={openSign}>
|
||||||
<FileSignature className="mr-2 size-4" />
|
<FileSignature className="mr-2 size-4" />
|
||||||
Sign contract
|
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -122,9 +150,13 @@ export default function BookingContractPage() {
|
|||||||
{signOpen && (
|
{signOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
|
||||||
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
|
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
|
||||||
<h2 className="text-lg font-semibold">Sign contract</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
|
{usingSaved ? "Approve signature" : "Sign contract"}
|
||||||
|
</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{data.reference} — your signature will be stored securely.
|
{usingSaved
|
||||||
|
? `${data.reference} — review your saved signature and approve it.`
|
||||||
|
: `${data.reference} — your signature will be stored securely.`}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
<label className="text-sm font-medium" htmlFor="portalSigner">
|
<label className="text-sm font-medium" htmlFor="portalSigner">
|
||||||
@@ -136,7 +168,29 @@ export default function BookingContractPage() {
|
|||||||
value={signerName}
|
value={signerName}
|
||||||
onChange={(e) => setSignerName(e.target.value)}
|
onChange={(e) => setSignerName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<ContractSignaturePad onChange={setSignatureData} />
|
{usingSaved ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={savedSignatureImage ?? undefined}
|
||||||
|
alt="Saved signature"
|
||||||
|
className="mx-auto h-36 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-primary underline"
|
||||||
|
onClick={() => {
|
||||||
|
setDrawNew(true);
|
||||||
|
setSignatureData(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Draw a new signature instead
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||||
@@ -145,19 +199,12 @@ export default function BookingContractPage() {
|
|||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
signMutation.isPending ||
|
signMutation.isPending ||
|
||||||
!signatureData ||
|
(!usingSaved && !signatureData) ||
|
||||||
!signerName.trim()
|
!signerName.trim()
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={confirmSign}
|
||||||
signMutation.mutate({
|
|
||||||
role: "CUSTOMER",
|
|
||||||
signatureImageBase64: signatureData!,
|
|
||||||
signerDisplayName: signerName.trim(),
|
|
||||||
consentText: "I agree to the terms of this contract.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Confirm signature
|
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export interface ContractView {
|
|||||||
signedAt: string;
|
signedAt: string;
|
||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
/** Current viewer's reusable saved signature, if they have one. */
|
||||||
|
savedSignature?: {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PriceLineItem {
|
export interface PriceLineItem {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
|
const SIGNATURE_URL = "/api/me/signature";
|
||||||
|
|
||||||
|
export interface SavedSignature {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveSignaturePayload {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const signaturesService = {
|
||||||
|
/** The current user's reusable saved signature, or null if none. */
|
||||||
|
getMySignature: async (): Promise<SavedSignature | null> => {
|
||||||
|
const { data } = await client.get(SIGNATURE_URL);
|
||||||
|
return (data.data ?? data) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveMySignature: async (
|
||||||
|
payload: SaveSignaturePayload,
|
||||||
|
): Promise<SavedSignature | null> => {
|
||||||
|
const { data } = await client.put(SIGNATURE_URL, payload);
|
||||||
|
return (data.data ?? data) ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user