diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 742d15278..cfe22845d 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -28,6 +28,7 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import OverviewPage from "./pages/dashboard/OverviewPage"; +import MyProfilePage from "./pages/dashboard/MyProfilePage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; @@ -257,6 +258,7 @@ const App = () => { } /> }> } /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index cc6454f60..d0dd0d04d 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -10,6 +10,7 @@ import { User, } from "lucide-react"; import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core"; +import { useNavigate } from "react-router-dom"; import type { PageMeta } from "./types"; import "./FreightDashboardHeader.css"; @@ -37,6 +38,7 @@ const FreightDashboardHeader = ({ theme, onToggleTheme, }: FreightDashboardHeaderProps) => { + const navigate = useNavigate(); const initials = userInitials ?? (userName @@ -200,7 +202,10 @@ const FreightDashboardHeader = ({ } - onClick={() => setIsUserMenuOpen(false)} + onClick={() => { + setIsUserMenuOpen(false); + navigate("/dashboard/profile"); + }} > Profile diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index bc57dcdeb..3c421090a 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -36,6 +36,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "Dashboard summary and key metrics", }, }, + { + prefix: "/dashboard/profile", + meta: { + title: "My Profile", + subtitle: "Manage your account and signature", + }, + }, { prefix: "/dashboard/operations/train-scheduling-v2/", meta: { diff --git a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx new file mode 100644 index 000000000..3ac661e1a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx @@ -0,0 +1,144 @@ +import { useState } from "react"; +import { FileSignature, Loader2 } from "lucide-react"; + +import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useAuth } from "@/auth/useAuth"; +import { + useMySignature, + useSaveSignature, +} from "@/hooks/useSavedSignature"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@edr/ui-common"; + +/** + * Lets the signed-in user 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, isLoading } = useMySignature(); + const saveMutation = useSaveSignature(); + + const [open, setOpen] = useState(false); + const [signerName, setSignerName] = useState(""); + const [signatureData, setSignatureData] = useState(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 ( + + + + + My signature + + + This signature can be reused to sign booking contracts. + + + + {isLoading ? ( +
+ +
+ ) : saved?.signatureImageUrl ? ( +
+
+ My saved signature +
+

+ Saved as {saved.signerDisplayName} +

+
+ ) : ( +

+ You have not saved a signature yet. +

+ )} + +
+ + + + + Save your signature + + Draw your signature below. It will be stored on your profile for + future contracts. + + +
+
+ + setSignerName(e.target.value)} + placeholder="As shown on contracts" + /> +
+ +
+ + + + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts b/apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts new file mode 100644 index 000000000..b8c9480a7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts @@ -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"), + }); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx index 7c22f9d45..4689d28fa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx @@ -40,6 +40,9 @@ export default function BookingContractPage() { const [signOpen, setSignOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); + // When the user has a saved signature we offer it for approval first; they + // can switch to drawing a fresh one. + const [drawNew, setDrawNew] = useState(false); const { data, isLoading, isError } = useQuery({ queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"], @@ -53,6 +56,12 @@ export default function BookingContractPage() { ? "STAFF" : null; + const savedSignature = data?.savedSignature ?? null; + const savedSignatureImage = savedSignature?.signatureImageUrl ?? null; + // Show the approval view only while a saved signature exists and the user + // hasn't opted to draw a new one. + const usingSaved = Boolean(savedSignatureImage) && !drawNew; + const signMutation = useMutation({ mutationFn: (payload: SignContractPayload) => bookingsService.signContract(id!, payload), @@ -88,16 +97,22 @@ export default function BookingContractPage() { }; const openSign = () => { - setSignerName(""); + // Prefill from the saved signature when available so the user only has to + // approve it; otherwise start with an empty pad. + setSignerName(savedSignature?.signerDisplayName ?? ""); setSignatureData(null); + setDrawNew(false); setSignOpen(true); }; const confirmSign = () => { - if (!signRole || !signatureData || !signerName.trim()) return; + if (!signRole || !signerName.trim()) return; + // Approve the saved signature, or submit the freshly drawn one. + const image = usingSaved ? savedSignatureImage : signatureData; + if (!image) return; signMutation.mutate({ role: signRole, - signatureImageBase64: signatureData, + signatureImageBase64: image, signerDisplayName: signerName.trim(), consentText: "I agree to the terms of this contract.", }); @@ -183,7 +198,9 @@ export default function BookingContractPage() { {signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"} - Sign to execute the contract for {data.reference}. + {usingSaved + ? `Review your saved signature and approve it to execute the contract for ${data.reference}.` + : `Sign to execute the contract for ${data.reference}.`}
@@ -196,7 +213,32 @@ export default function BookingContractPage() { placeholder="As shown on the contract" />
- + {usingSaved ? ( +
+ +
+ Saved signature +
+ +
+ ) : ( + + )}