add company stamp upload functionality for contract signing

- Introduced StampUpload component for uploading company stamp images.
- Integrated stamp upload in contract signing modal, supporting PNG and JPG formats.
- Implemented validation for file type and size (max 5 MB).
- Added visual feedback for drag-and-drop functionality.
- Updated contract-related pages to handle duplicate contract alerts and pricing notices.
- Enhanced contract expiry management with a nightly sweep service.
- Added unit tests for new features and updated existing tests for contract handling.
This commit is contained in:
Marshal
2026-07-25 17:14:58 +00:00
parent 54b5882355
commit fde5e6de4b
68 changed files with 1858 additions and 289 deletions

View File

@@ -27,8 +27,8 @@ import {
User,
X,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
@@ -123,14 +123,25 @@ function formatDate(value: string | null | undefined): string {
export default function BookingRequestsPage() {
const navigate = useNavigate();
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
// the header's document-review alarm opens exactly the undecided requests it
// is counting down for. Read once as the initial state so staff can then
// change the filters like any other visit.
const [searchParams] = useSearchParams();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// Booking kind is a filter now — one list holds both kinds (null = "all").
const [kindFilter, setKindFilter] = useState<BookingKind | null>(null);
// Filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const paramStatuses = searchParams.get("statuses") ?? "";
const paramDirection = searchParams.get("tradeDirection");
const [statusFilter, setStatusFilter] = useState<string[]>(() =>
paramStatuses.split(",").filter(Boolean),
);
const [directionFilter, setDirectionFilter] = useState<string | null>(
paramDirection,
);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [paymentStatusFilter, setPaymentStatusFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
@@ -150,6 +161,15 @@ export default function BookingRequestsPage() {
}, 400);
}, []);
// Follow the URL when a deep link arrives while the page is already open
// (clicking the header alarm from this very list). Same-value writes are
// dropped so a manual filter change is never undone.
useEffect(() => {
const next = paramStatuses.split(",").filter(Boolean);
setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next));
setDirectionFilter(paramDirection);
}, [paramStatuses, paramDirection]);
const filter: BookingListFilter = useMemo(() => {
return {
page: pagination.pageIndex + 1,

View File

@@ -18,7 +18,9 @@ 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";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
@@ -38,6 +40,7 @@ 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,6 +59,7 @@ export default function ContractViewPage() {
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData ?? ""),
stampImageBase64: stampData ?? "",
signerDisplayName: signerName.trim(),
consentText: "I confirm this contract on behalf of EDR.",
}),
@@ -66,7 +70,12 @@ export default function ContractViewPage() {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
},
onError: () => toast.error("Failed to sign contract"),
// Surface the server's reason verbatim — the missing-customer-stamp gate
// and the status guards all explain themselves in the message.
onError: (err) =>
toast.error(
extractApiError(err).message ?? "Failed to sign contract",
),
});
const handlePrint = () => iframeRef.current?.contentWindow?.print();
@@ -89,12 +98,13 @@ export default function ContractViewPage() {
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setStampData(null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signerName.trim()) return;
if (!signerName.trim() || !stampData) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate();
@@ -231,6 +241,14 @@ 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
@@ -241,7 +259,8 @@ export default function ContractViewPage() {
disabled={
signMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData)
(!usingSaved && !signatureData) ||
!stampData
}
onClick={confirmSign}
>

View File

@@ -1180,22 +1180,16 @@ export function LocomotivesCrudPage() {
// of the weight tolerance.
{ key: 'overageToleranceTons', label: 'Weight tolerance (tons over max pull)', type: 'number' },
{ key: 'overageToleranceMeters', label: 'Length tolerance (meters over max length)', type: 'number' },
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
]}
emptyValues={{
code: '',
name: '',
locomotiveType: 'DIESEL',
status: 'AVAILABLE',
maxPullWeightTons: 0,
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
overageToleranceTons: '',
overageToleranceMeters: '',
powerKw: '',
tractionForceKn: '',
maxSpeedKmh: '',
overageToleranceTons: 93,
overageToleranceMeters: 10,
}}
/>
);

View File

@@ -233,22 +233,16 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
// of the weight tolerance.
{ name: "overageToleranceTons", label: "Weight tolerance (tons over max pull)", type: "number" },
{ name: "overageToleranceMeters", label: "Length tolerance (meters over max length)", type: "number" },
{ name: "powerKw", label: "Power (kW)", type: "number" },
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
],
emptyValues: {
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
currentYardId: "",
maxPullWeightTons: 2500,
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
overageToleranceTons: "",
overageToleranceMeters: "",
powerKw: "",
tractionForceKn: "",
maxSpeedKmh: "",
overageToleranceTons: 93,
overageToleranceMeters: 10,
},
},
{

View File

@@ -59,6 +59,7 @@ import {
RULE_ENGINE_CATEGORY_BASE_PATH,
RULE_ENGINE_SELECT_NONE,
getRuleEngineResource,
rateUnitOptions,
type RuleEngineNavCategory,
} from "@/pages/ruleEngine/config/resources";
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
@@ -344,6 +345,20 @@ const RuleEngineResourcePage = () => {
options: cargoLeafOptions ?? [],
};
}
// Rate units follow the picked commodity: a per-item (break-bulk) cargo
// is priced per item where a weighed one is priced per ton.
if (field.name === "rateUnit" && field.optionsFromValues) {
return {
...field,
optionsFromValues: (values: Record<string, unknown>) =>
rateUnitOptions(
values,
(cargoLeafOptions ?? []).find(
(o) => o.value === String(values.cargoTypeId ?? ""),
)?.unitOfMeasure ?? "",
),
};
}
if (field.name === "rateId") {
return {
...field,

View File

@@ -207,13 +207,27 @@ const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value
/**
* Valid weighting units for a rate shape — mirrors the API's
* `allowedRateUnits`. The unit is driven by the *type* being billed: containers
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
* bill per container, bulk per ton, overweight always per excess ton, etc. A
* rate scoped to a break-bulk commodity (unit of measure = PER_ITEM) offers
* PER_ITEM wherever a weighed one offers PER_TON. Kept in sync with
* apps/edr-freight-api/.../entities/rate-unit.util.ts.
*/
const allowedRateUnits = (
appliesTo: string,
trigger: string,
cargoKind = "",
cargoUnitOfMeasure = "",
): string[] => {
const units = unitsForShape(appliesTo, trigger, cargoKind);
return cargoUnitOfMeasure === "PER_ITEM"
? units.map((u) => (u === "PER_TON" ? "PER_ITEM" : u))
: units;
};
const unitsForShape = (
appliesTo: string,
trigger: string,
cargoKind = "",
): string[] => {
if (appliesTo === "OTHER") {
switch (trigger) {
@@ -259,7 +273,15 @@ const allowedRateUnits = (
}
};
const rateUnitOptions = (values: Record<string, unknown>) => {
/**
* Unit choices for the rate form. `cargoUnitOfMeasure` is how the bulk
* commodity picked in the form is counted (PER_TON / PER_ITEM) — injected by
* RuleEngineResourcePage, which is the layer that has the cargo type list.
*/
export const rateUnitOptions = (
values: Record<string, unknown>,
cargoUnitOfMeasure = "",
) => {
const appliesTo = String(values.appliesTo ?? "");
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
if (!appliesTo) return [];
@@ -267,6 +289,7 @@ const rateUnitOptions = (values: Record<string, unknown>) => {
appliesTo,
trigger,
String(values.cargoKind ?? ""),
cargoUnitOfMeasure,
).map(unitOption);
};
@@ -889,7 +912,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
optionsFromValues: rateUnitOptions,
description: "Weighting basis — options depend on what the rate applies to.",
description:
"Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item).",
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
},
],