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

@@ -0,0 +1,70 @@
import { allowedRateUnits, isBulkQuantityUnit } from "./rate-unit.util";
/**
* A bulk rate's weighting unit follows how its commodity is counted: wheat is
* weighed (per ton), machinery is counted (per item). Per-wagon is offered
* either way.
*/
describe("allowedRateUnits — bulk unit of measure", () => {
it("offers per-ton for a weighed commodity", () => {
expect(
allowedRateUnits({
appliesTo: "BULK",
trigger: "ALWAYS",
cargoUnitOfMeasure: "PER_TON",
}),
).toEqual(["PER_TON", "PER_WAGON"]);
});
it("offers per-item for a counted commodity", () => {
expect(
allowedRateUnits({
appliesTo: "BULK",
trigger: "ALWAYS",
cargoUnitOfMeasure: "PER_ITEM",
}),
).toEqual(["PER_ITEM", "PER_WAGON"]);
});
it("falls back to per-ton when the rate is not scoped to a commodity", () => {
expect(allowedRateUnits({ appliesTo: "BULK", trigger: "ALWAYS" })).toEqual([
"PER_TON",
"PER_WAGON",
]);
});
it("swaps the per-ton slot for counted commodities on every bulk-capable shape", () => {
expect(
allowedRateUnits({
appliesTo: "OTHER",
trigger: "CUSTOMS_CLEARANCE",
cargoKind: "BULK",
cargoUnitOfMeasure: "PER_ITEM",
}),
).toEqual(["PER_ITEM", "PER_WAGON"]);
expect(
allowedRateUnits({
appliesTo: "INTERCITY",
trigger: "ALWAYS",
cargoUnitOfMeasure: "PER_ITEM",
}),
).toEqual(["PER_CONTAINER", "PER_ITEM", "PER_WAGON", "PER_KM"]);
});
it("never offers per-item for overweight, which is always per excess ton", () => {
expect(
allowedRateUnits({
appliesTo: "OTHER",
trigger: "OVERWEIGHT",
cargoUnitOfMeasure: "PER_TON",
}),
).toEqual(["PER_TON"]);
});
it("treats per-ton and per-item as the same booking quantity", () => {
expect(isBulkQuantityUnit("PER_TON")).toBe(true);
expect(isBulkQuantityUnit("PER_ITEM")).toBe(true);
expect(isBulkQuantityUnit("PER_WAGON")).toBe(false);
expect(isBulkQuantityUnit("FLAT")).toBe(false);
});
});

View File

@@ -1,5 +1,17 @@
import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
/** How the bulk commodity a rate is scoped to is counted (cargo_types.unit_of_measure). */
export type CargoUom = 'PER_TON' | 'PER_ITEM' | null | undefined;
/**
* Units billed against a booking's bulk quantity. That quantity is recorded in
* the commodity's own unit — tonnes for a PER_TON commodity, item count for a
* PER_ITEM one — so both units scale off the same field and only differ in what
* they are called.
*/
export const isBulkQuantityUnit = (unit: string): boolean =>
unit === 'PER_TON' || unit === 'PER_ITEM';
/**
* Which rate units make sense for a given rate shape. The weighting basis is
* driven by the *type* of thing being billed — a container leg bills per
@@ -8,6 +20,10 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
* only pick a unit the pricing engine knows how to apply.
*
* A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers
* PER_ITEM wherever a weighed commodity offers PER_TON — machinery is priced
* per unit shipped, wheat per tonne. Per-wagon is offered either way.
*
* Returned lists are ordered with the most natural/default unit first.
*/
export function allowedRateUnits(input: {
@@ -15,6 +31,19 @@ export function allowedRateUnits(input: {
trigger: RateTrigger;
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null;
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
cargoUnitOfMeasure?: CargoUom;
}): RateUnit[] {
const units = unitsForShape(input);
return input.cargoUnitOfMeasure === 'PER_ITEM'
? units.map((u) => (u === 'PER_TON' ? 'PER_ITEM' : u))
: units;
}
function unitsForShape(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
cargoKind?: 'CONTAINER' | 'BULK' | null;
}): RateUnit[] {
const { appliesTo, trigger } = input;
@@ -81,6 +110,7 @@ export function isRateUnitAllowed(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
cargoKind?: 'CONTAINER' | 'BULK' | null;
cargoUnitOfMeasure?: CargoUom;
unit: RateUnit;
}): boolean {
return allowedRateUnits(input).includes(input.unit);

View File

@@ -34,6 +34,9 @@ export type RateStatus = typeof RATE_STATUSES[number];
export const RATE_UNITS = [
'PER_WAGON',
'PER_TON',
// Break-bulk commodities are counted, not weighed (cargo_types.unit_of_measure
// = PER_ITEM) — their rates bill per item off the same booking quantity field.
'PER_ITEM',
'PER_CONTAINER',
'PER_KM',
'PER_INVOICE',