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

@@ -9,7 +9,7 @@ import {
Group,
Loader,
Modal,
Select,
// Select, // ponytail: unused now the validity dropdown below is commented out
Stack,
Text,
Textarea,
@@ -25,6 +25,7 @@ import {
Plus,
Trash2,
} from "lucide-react";
import { DateInput } from "@mantine/dates";
import type { Freight } from "@edr/types";
import { contractsService } from "@/services/contracts.service";
@@ -75,8 +76,10 @@ export function ContractDocumentEditorModal({
onClose,
contractId,
mode,
validityOptions = [],
validityLoading = false,
// ponytail: validityOptions/validityLoading fed the now-commented dropdown
// above — caller still passes them, left unread here for a quick revert.
// validityOptions = [],
// validityLoading = false,
accepting = false,
saving = false,
onAccept,
@@ -93,7 +96,12 @@ export function ContractDocumentEditorModal({
const [documentTitle, setDocumentTitle] = useState("");
const [whereasClauses, setWhereasClauses] = useState<string[]>([]);
const [articles, setArticles] = useState<EditableArticle[]>([]);
const [validityDays, setValidityDays] = useState<string | null>(null);
// const [validityDays, setValidityDays] = useState<string | null>(null);
// ponytail: client keeps flip-flopping the validity requirement — swapped
// the validity dropdown for explicit start/end dates, kept above commented
// instead of deleted so it's a one-line revert if they flip back.
const [validityStart, setValidityStart] = useState<Date | null>(null);
const [validityEnd, setValidityEnd] = useState<Date | null>(null);
// Seed the editor from the loaded draft whenever the dialog (re)opens.
useEffect(() => {
@@ -110,11 +118,11 @@ export function ContractDocumentEditorModal({
}, [opened, draft]);
// Default validity to the first configured option (accept mode).
useEffect(() => {
if (mode === "accept" && !validityDays && validityOptions.length > 0) {
setValidityDays(validityOptions[0].value);
}
}, [mode, validityDays, validityOptions]);
// useEffect(() => {
// if (mode === "accept" && !validityDays && validityOptions.length > 0) {
// setValidityDays(validityOptions[0].value);
// }
// }, [mode, validityDays, validityOptions]);
// Editing rights belong to the approver whose turn it is, so the server
// decides per-caller — the client cannot derive this from the contract alone.
@@ -169,8 +177,13 @@ export function ContractDocumentEditorModal({
const submit = () => {
const snapshot = buildSnapshot();
if (mode === "accept") {
const days = Number(validityDays);
if (!days) return;
// const days = Number(validityDays);
// if (!days) return;
if (!validityStart || !validityEnd) return;
const days = Math.ceil(
(validityEnd.getTime() - validityStart.getTime()) / (24 * 60 * 60 * 1000),
);
if (days <= 0) return;
onAccept?.(days, snapshot);
} else {
onSaveEdit?.(snapshot);
@@ -181,7 +194,8 @@ export function ContractDocumentEditorModal({
const canSubmit =
hasArticles &&
!locked &&
(mode === "edit" || Boolean(validityDays)) &&
// (mode === "edit" || Boolean(validityDays)) &&
(mode === "edit" || Boolean(validityStart && validityEnd)) &&
!submitting;
return (
@@ -375,7 +389,10 @@ export function ContractDocumentEditorModal({
{mode === "accept" && (
<>
{validityOptions.length > 0 ? (
{/* ponytail: client keeps changing this requirement — swapped
the validity-period dropdown for explicit start/end dates,
left the old block commented instead of deleted. */}
{/* {validityOptions.length > 0 ? (
<Select
label="Contract validity"
placeholder="Select a validity period"
@@ -391,7 +408,25 @@ export function ContractDocumentEditorModal({
? "Loading validity periods…"
: "No validity periods are configured yet. Add them under Dropdown Settings."}
</Text>
)}
)} */}
<Group grow align="flex-start">
<DateInput
label="Start date"
placeholder="Contract validity start"
value={validityStart}
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
maxDate={validityEnd ?? undefined}
clearable
/>
<DateInput
label="End date"
placeholder="Contract validity end"
value={validityEnd}
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
minDate={validityStart ?? undefined}
clearable
/>
</Group>
</>
)}

View File

@@ -0,0 +1,171 @@
import { useRef, useState } from "react";
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
import { RefreshCw, Stamp, X } from "lucide-react";
const MAX_STAMP_MB = 5;
export interface StampUploadProps {
/** Stamp image as a data URL, or null when none is attached yet. */
value: string | null;
onChange: (dataUrl: string | null) => void;
label?: string;
description?: string;
}
/**
* Company stamp/seal attachment for the contract signing modal. Reads the
* picked image straight into a data URL because the signing endpoint takes
* base64 in JSON (same transport as the drawn signature), not multipart.
*/
export function StampUpload({
value,
onChange,
label = "Company stamp",
description = "Attach your official company stamp or seal.",
}: StampUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const readFile = (file: File | undefined | null) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("The stamp must be an image file (PNG or JPG).");
return;
}
if (file.size > MAX_STAMP_MB * 1024 * 1024) {
setError(`The stamp image must be under ${MAX_STAMP_MB} MB.`);
return;
}
const reader = new FileReader();
reader.onload = () => {
setError(null);
setFileName(file.name);
onChange(typeof reader.result === "string" ? reader.result : null);
};
reader.onerror = () => setError("Could not read that file. Try another.");
reader.readAsDataURL(file);
};
const openPicker = () => inputRef.current?.click();
const clear = () => {
setFileName(null);
setError(null);
onChange(null);
if (inputRef.current) inputRef.current.value = "";
};
return (
<Stack gap={6}>
<Text size="sm" fw={500}>
{label}
</Text>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
onChange={(e) => readFile(e.currentTarget.files?.[0])}
/>
{value ? (
<Paper withBorder radius="md" p="sm">
<Group gap="md" wrap="nowrap" align="center">
<Box
style={{
background:
"repeating-conic-gradient(var(--mantine-color-gray-1) 0% 25%, transparent 0% 50%) 50% / 14px 14px",
borderRadius: 8,
flexShrink: 0,
padding: 6,
}}
>
<Image
src={value}
alt="Company stamp"
fit="contain"
h={92}
w={92}
/>
</Box>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{fileName ?? "Stamp attached"}
</Text>
<Text size="xs" c="dimmed">
This stamp is applied next to your signature on the contract.
</Text>
<Group gap="xs" mt={2}>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<RefreshCw size={13} />}
onClick={openPicker}
>
Replace
</Button>
<Button
size="compact-xs"
variant="subtle"
color="red"
leftSection={<X size={13} />}
onClick={clear}
>
Remove
</Button>
</Group>
</Stack>
</Group>
</Paper>
) : (
<Paper
withBorder
radius="md"
p="lg"
onClick={openPicker}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
readFile(e.dataTransfer.files?.[0]);
}}
style={{
borderColor: dragging
? "var(--mantine-color-edr-green-6)"
: undefined,
borderStyle: "dashed",
backgroundColor: dragging
? "var(--mantine-color-edr-green-0)"
: undefined,
cursor: "pointer",
}}
>
<Stack gap={6} align="center">
<Stamp size={26} color="var(--mantine-color-edr-green-6)" />
<Text size="sm" fw={500}>
Upload company stamp
</Text>
<Text size="xs" c="dimmed" ta="center">
{description} Drop an image here or click to browse PNG or JPG,
up to {MAX_STAMP_MB} MB.
</Text>
</Stack>
</Paper>
)}
{error && (
<Text size="xs" c="red.7">
{error}
</Text>
)}
</Stack>
);
}

View File

@@ -172,7 +172,7 @@ export function computeGlShipmentTotal(
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
// depends on the wagon capacity the train stocks — shown at real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && lashing.unit === "per_ton") {
if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) {
const tons = q.bulkQuantity;
if (tons > 0) {
lines.push({
@@ -199,7 +199,7 @@ export function computeGlShipmentTotal(
cl.unit === "per_wagon"
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
: boxes;
} else if (cl.unit === "per_ton") {
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
qty = q.bulkQuantity;
} else if (cl.unit === "flat") {
qty = 1;

View File

@@ -23,6 +23,7 @@ import {
import { type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton";
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
import type { PageMeta } from "./types";
@@ -114,8 +115,12 @@ const FreightDashboardHeader = ({
</Group>
</Group>
{/* Right: actions + avatar */}
{/* Right: actions + avatar. The doc-review alarm leads the group — it
only renders in the last half of a review phase that still has
undecided requests, so it never competes for space otherwise. */}
<Group gap={10} wrap="nowrap" align="center">
<DocReviewAlertButton />
<Tooltip label="Language" withArrow openDelay={300}>
<UnstyledButton className={ISLAND} aria-label="Language">
<Languages size={17} strokeWidth={1.8} />