feat(freight-api): seal EDR side of contracts with the one global stamp

This commit is contained in:
ghost2023
2026-08-12 06:26:46 +03:00
parent e52d666647
commit a603807e8e
21 changed files with 679 additions and 183 deletions

View File

@@ -1,11 +1,10 @@
import { useState } from "react";
import { FileSignature, Loader2, Stamp } from "lucide-react";
import { FileSignature, Loader2 } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import {
Card,
CardContent,
@@ -27,9 +26,13 @@ import {
} from "@edr/ui-common";
/**
* Lets the signed-in user view and update the reusable signature and company
* stamp stored on their profile — managed independently of each other. Both
* are offered when signing a booking contract.
* Lets the signed-in user view and update the reusable signature stored on
* their profile, offered for approval when signing a contract.
*
* Signature only — there is no per-employee stamp. EDR seals with ONE global
* company stamp, managed under Settings and applied server-side, so a staff
* member never uploads or picks a stamp. (Customers do upload their own, in
* the portal — that is a different card.)
*/
export function MySignatureCard() {
const { user } = useAuth();
@@ -39,10 +42,8 @@ export function MySignatureCard() {
const saveMutation = useMutation(api.signatures.save.mutationOptions());
const [signatureOpen, setSignatureOpen] = useState(false);
const [stampOpen, setStampOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null);
const defaultName =
user?.name?.en || user?.username || user?.email || "";
@@ -60,7 +61,6 @@ export function MySignatureCard() {
{
signerDisplayName: signerName.trim(),
signatureImageBase64: signatureData,
// Stamp untouched — it is managed by its own dialog.
},
{
onSuccess: () => {
@@ -72,38 +72,16 @@ export function MySignatureCard() {
);
};
const openStampDialog = () => {
setStampData(saved?.stampImageUrl ?? null);
setStampOpen(true);
};
const saveStamp = () => {
if (!stampData) return;
saveMutation.mutate(
{
signerDisplayName: savedName || defaultName,
// Signature untouched — stamp-only update.
stampImageBase64: stampData,
},
{
onSuccess: () => {
toast.success("Stamp saved");
setStampOpen(false);
},
onError: () => toast.error("Failed to save stamp"),
},
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileSignature className="size-4" />
Signature &amp; Stamp
Signature
</CardTitle>
<CardDescription>
This signature can be reused to sign booking contracts.
This signature can be reused to sign booking contracts. The EDR
company stamp is applied automatically you do not upload one.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@@ -112,54 +90,29 @@ export function MySignatureCard() {
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : (
<>
<div className="space-y-2">
{saved?.signatureImageUrl ? (
<>
<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>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
<div className="space-y-2">
{saved?.signatureImageUrl ? (
<>
<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>
)}
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</div>
<div className="space-y-2">
{saved?.stampImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.stampImageUrl}
alt="My saved company stamp"
className="mx-auto h-24 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">Company stamp</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not uploaded a company stamp yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openStampDialog}>
<Stamp className="size-4" />
{saved?.stampImageUrl ? "Update stamp" : "Upload stamp"}
</Button>
</div>
</>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</div>
)}
</CardContent>
@@ -203,39 +156,6 @@ export function MySignatureCard() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={stampOpen} onOpenChange={setStampOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Company stamp</DialogTitle>
<DialogDescription>
Upload your official company stamp or seal as an image. It is
stored on your profile and applied next to your signature on
contracts.
</DialogDescription>
</DialogHeader>
<StampUpload
value={stampData}
onChange={setStampData}
description="Stored on your profile and prefilled when you sign contracts."
/>
<DialogFooter>
<Button variant="outline" onClick={() => setStampOpen(false)}>
Cancel
</Button>
<Button
disabled={saveMutation.isPending || !stampData}
onClick={saveStamp}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Save stamp"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -12,7 +12,6 @@ import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
@@ -41,9 +40,6 @@ export default function BookingContractPage() {
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// Company stamp: prefilled from the profile, or uploaded here when none is
// saved yet.
const [stampData, setStampData] = useState<string | null>(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);
@@ -59,7 +55,6 @@ export default function BookingContractPage() {
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
const savedStampImage = savedSignature?.stampImageUrl ?? 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;
@@ -103,8 +98,6 @@ export default function BookingContractPage() {
// approve it; otherwise start with an empty pad.
setSignerName(savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
// Prefill with the reusable stamp saved on the profile; still replaceable.
setStampData(savedStampImage);
setDrawNew(false);
setSignOpen(true);
};
@@ -113,12 +106,12 @@ export default function BookingContractPage() {
if (!canSign || !signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData;
// The API rejects a STAFF signature without a stamp.
if (!image || !stampData) return;
if (!image) return;
// No stamp is sent: EDR's seal is the ONE global company stamp, applied
// server-side at render time (see StampSettingsService).
signMutation.mutate({
role: "STAFF",
signatureImageBase64: image,
stampImageBase64: stampData,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
});
@@ -243,15 +236,6 @@ export default function BookingContractPage() {
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
description={
savedStampImage
? "Your saved company stamp — replace it for this contract if needed."
: "Required. Attach your official company stamp or seal."
}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSignOpen(false)}>
@@ -261,7 +245,6 @@ export default function BookingContractPage() {
disabled={
signMutation.isPending ||
(!usingSaved && !signatureData) ||
!stampData ||
!signerName.trim()
}
onClick={confirmSign}

View File

@@ -18,7 +18,6 @@ import toast from "react-hot-toast";
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { contractsService } from "@/services/contracts.service";
import { extractApiError } from "@/utils/result";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -40,7 +39,6 @@ export default function ContractViewPage() {
const [successOpen, setSuccessOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null);
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
@@ -56,10 +54,11 @@ export default function ContractViewPage() {
mutationFn: () =>
contractsService.signContract(id!, {
role: "STAFF",
// No stamp is sent: EDR's seal is the ONE global company stamp, applied
// server-side from StampSettingsService when the signature is stored.
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData ?? ""),
stampImageBase64: stampData ?? "",
signerDisplayName: signerName.trim(),
consentText: "I confirm this contract on behalf of EDR.",
}),
@@ -98,14 +97,12 @@ export default function ContractViewPage() {
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
// Prefill with the reusable stamp saved on the profile; still replaceable.
setStampData(data?.savedSignature?.stampImageUrl ?? null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signerName.trim() || !stampData) return;
if (!signerName.trim()) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate();
@@ -243,13 +240,6 @@ export default function ContractViewPage() {
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
label="EDR company stamp"
description="Attach the official EDR stamp or seal — it is applied to the contract next to the signature."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
@@ -260,8 +250,7 @@ export default function ContractViewPage() {
disabled={
signMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData) ||
!stampData
(!usingSaved && !signatureData)
}
onClick={confirmSign}
>

View File

@@ -111,11 +111,14 @@ export interface ConsolidationDetails {
splitBilling: { bookingShare: number; partnerShare: number } | null;
}
/**
* No stamp field: the backoffice only ever signs as STAFF, and EDR's seal is
* the ONE global company stamp applied server-side. Customer stamps are posted
* from the portal, not here.
*/
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}

View File

@@ -118,11 +118,14 @@ export interface ContractView {
} | null;
}
/**
* No stamp field: the backoffice only ever counter-signs as STAFF, and EDR's
* seal is the ONE global company stamp, snapshotted server-side from
* StampSettingsService when the signature is stored.
*/
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; required to sign a contract. */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}

View File

@@ -9,12 +9,16 @@ export interface SavedSignature {
stampImageUrl?: string | null;
}
/**
* Signature only. A backoffice employee has no personal stamp — EDR seals with
* the one global company stamp managed under Settings — so the stamp half of
* PUT /me/signature is deliberately not exposed here, even though the shared
* endpoint still accepts it for portal (customer) users.
*/
export interface SaveSignaturePayload {
signerDisplayName: string;
/** Omit to keep the existing saved signature (stamp-only update). */
/** Omit to keep the existing saved signature. */
signatureImageBase64?: string;
/** Omit to keep the existing saved stamp. */
stampImageBase64?: string;
}
export const signaturesService = {