add quantity cap for GENERAL contracts and implement capacity tracking

- Updated ContractClearanceService and ContractsController to remove region parameter from queue method.
- Enhanced ContractsRepository to attach contract files for download and added attachContractFiles method.
- Modified ContractsService to persist cargo scope with quantity cap based on contract kind.
- Introduced quantityCap field in CreateContractCargoScopeDto and ContractCargoScope entity.
- Implemented capacity tracking in the frontend with ContractCapacityNotice component to display remaining bookable quantities.
- Updated various components and services to support new capacity features, including hooks and API calls.
- Added migration to include quantity_cap column in contract_cargo_scope table.
This commit is contained in:
Marshal
2026-06-28 17:32:18 +00:00
parent e1d54746c2
commit 11c7f1bb74
27 changed files with 512 additions and 74 deletions

View File

@@ -2,6 +2,8 @@ import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Center,
@@ -29,9 +31,11 @@ import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import {
useContractCapacity,
useContractDetail,
useContractMutations,
} from "@/hooks/contracts/useContracts";
import { Boxes } from "lucide-react";
interface UnitDraft {
containerNumber: string;
@@ -61,6 +65,7 @@ export default function GlCreateBookingForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const { data: capacity = [] } = useContractCapacity(id);
const mutations = useContractMutations(id ?? "");
const [scheduledDate, setScheduledDate] = useState("");
@@ -221,6 +226,28 @@ export default function GlCreateBookingForm() {
/>
<Stack gap="lg">
{capacity.length > 0 && (
<Alert
color={capacity.every((c) => c.remaining === 0) ? "red" : "blue"}
variant="light"
radius="md"
icon={<Boxes size={16} />}
title="Contract draw-down capacity"
>
<Group gap={8} wrap="wrap">
{capacity.map((c, i) => (
<Badge
key={i}
color={c.remaining === 0 ? "red" : "blue"}
variant="light"
radius="sm"
>
{c.containerSize ?? "Bulk"}: {c.remaining} of {c.cap} left
</Badge>
))}
</Group>
</Alert>
)}
<SectionCard icon={FileText} title="Schedule">
<Grid gap="md">
<Grid.Col span={{ base: 12, sm: 6 }}>

View File

@@ -57,6 +57,7 @@ export const QUERY_KEYS = {
clearanceQueue: (region?: string) =>
["contracts", "clearance-queue", region ?? "ET"] as const,
milestones: (id: string) => ["contracts", "milestones", id] as const,
capacity: (id: string) => ["contracts", "capacity", id] as const,
bookingMilestones: (bookingId: string) =>
["contracts", "booking-milestones", bookingId] as const,
bookingIncidents: (bookingId: string) =>

View File

@@ -150,6 +150,7 @@ export const URL_CONSTANTS = {
OPS_CLEARANCE_FINALIZE: (id: string) =>
`/contracts/${id}/clearance/ops-finalize`,
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
BOOKING_MILESTONES: (bookingId: string) =>
`/contracts/bookings/${bookingId}/milestones`,

View File

@@ -44,10 +44,10 @@ export function useContractDetail(id: string | undefined) {
});
}
export function useContractClearanceQueue(region = "ET", enabled = true) {
export function useContractClearanceQueue(enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue(region),
queryFn: () => contractsService.getClearanceQueue(region),
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("GL"),
queryFn: () => contractsService.getClearanceQueue(),
enabled,
});
}
@@ -69,6 +69,14 @@ export function useContractMilestones(id: string | undefined) {
});
}
export function useContractCapacity(id: string | undefined) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.capacity(id ?? ""),
queryFn: () => contractsService.getCapacity(id!),
enabled: Boolean(id),
});
}
export function useBookingMilestones(bookingId: string | undefined) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId ?? ""),

View File

@@ -46,7 +46,6 @@ import {
} from "@/hooks/contracts/useContracts";
type ViewMode = "table" | "cards";
type Region = "ET" | "DJ";
interface ClearanceRow {
id: string;
@@ -118,12 +117,11 @@ export default function ContractClearanceListPage({
opsMode?: boolean;
} = {}) {
const navigate = useNavigate();
const [region, setRegion] = useState<Region>("ET");
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const glQueue = useContractClearanceQueue(region, !opsMode);
const glQueue = useContractClearanceQueue(!opsMode);
const opsQueue = useOpsClearanceQueue(opsMode);
const { data, isLoading, isError, isFetching, refetch } = opsMode
? opsQueue
@@ -340,24 +338,6 @@ export default function ContractClearanceListPage({
style={{ flex: 1, minWidth: 220 }}
/>
<Group gap="sm" wrap="nowrap">
{!opsMode && (
<SegmentedControl
size="sm"
radius="md"
value={region}
onChange={(v) => {
setRegion(v as Region);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
data={[
{ value: "ET", label: "Ethiopia" },
{ value: "DJ", label: "Djibouti" },
]}
/>
)}
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>

View File

@@ -163,12 +163,8 @@ export const contractsService = {
postContract<Freight.IContract>(C.CONTRACT_SIGN(id), payload),
// ── Pre-booking clearance (Path B — GL ET) ──
getClearanceQueue: async (
region = "ET",
): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(C.CLEARANCE_QUEUE, {
params: { region },
});
getClearanceQueue: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(C.CLEARANCE_QUEUE);
const data = unwrap(response.data);
return {
items: (data.items ?? []) as Freight.IContract[],
@@ -230,6 +226,12 @@ export const contractsService = {
payload: Freight.CreateBookingUnderContractDto,
) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload),
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
const response = await client.get(C.CAPACITY(id));
return (unwrap(response.data) ?? []) as Freight.ContractCapacityLine[];
},
// ── Clearance milestones ──
listMilestonesForContract: async (
id: string,

View File

@@ -129,6 +129,7 @@ export const URL_CONSTANTS = {
`/api/contracts/bookings/${bookingId}/milestones`,
BOOKING_DUTY_SLIP: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/duty-slip`,
CAPACITY: (id: string) => `/api/contracts/${id}/capacity`,
},
TRAIN_SCHEDULING: {

View File

@@ -5,7 +5,6 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import {
ActionNeededSection,
FreightVolumeSection,
HelloSection,
InvoicesSection,
@@ -14,7 +13,6 @@ import {
ShipmentsSection,
StatsSection,
} from "./components";
import { deriveActionItems } from "./actions";
import { useMyPortalData } from "./hooks";
export default function MyPortalPage() {
@@ -27,7 +25,6 @@ export default function MyPortalPage() {
bookingsQuery,
dashboardQuery,
contractsQuery,
allContracts,
recentContracts,
activeContractsCount,
allBookings,
@@ -48,8 +45,6 @@ export default function MyPortalPage() {
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
}));
const actionItems = deriveActionItems(allContracts, allBookings);
const handleBookingClick = (id: string) => {
navigate(`/bookings/${id}`);
};
@@ -58,8 +53,6 @@ export default function MyPortalPage() {
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<HelloSection greeting={greeting} companyName={companyName} />
<ActionNeededSection items={actionItems} contracts={allContracts} />
{serviceOptions.length > 1 && (
<Group justify="flex-end">
<Select
@@ -98,36 +91,36 @@ export default function MyPortalPage() {
dashboardLoading={dashboardQuery.isPending}
/>
{/* Contracts lead the dashboard; bookings live under their contract. */}
{/* Contracts + shipments side by side — the two primary tables. */}
<Grid align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Grid.Col span={{ base: 12, lg: 6 }}>
<RecentContractsSection
contracts={recentContracts}
isLoading={contractsQuery.isPending}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<InvoicesSection invoices={recentInvoices} />
</Grid.Col>
</Grid>
<Grid align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Grid.Col span={{ base: 12, lg: 6 }}>
<ShipmentsSection
bookings={allBookings.slice(0, 6)}
isLoading={bookingsQuery.isPending}
onBookingClick={handleBookingClick}
/>
</Grid.Col>
</Grid>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Grid align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<RecentActivitySection
bookings={allBookings}
isLoading={bookingsQuery.isPending}
onBookingClick={handleBookingClick}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<InvoicesSection invoices={recentInvoices} />
</Grid.Col>
</Grid>
<Grid align="stretch">

View File

@@ -3,7 +3,10 @@ import { memo } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, FileSignature, Package, Plus } from "lucide-react";
import type { Freight } from "@edr/types";
import { ContractStatusBadge } from "@/pages/contracts/contract-ui";
import {
ContractDocButton,
ContractStatusBadge,
} from "@/pages/contracts/contract-ui";
import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
@@ -87,7 +90,11 @@ export const RecentContractsSection = memo(function RecentContractsSection({
</Text>
</Box>
<Group gap={10} wrap="nowrap" style={{ flexShrink: 0 }}>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<ContractDocButton
contract={c}
onClick={(e) => e.stopPropagation()}
/>
<ContractStatusBadge status={c.status} />
{canSign ? (
<Button

View File

@@ -97,18 +97,16 @@ interface DocGroup {
* groups are dropped so the tab only renders sections that have files.
*/
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
const contract: ContractFile[] = [];
const profile: ContractFile[] = [];
const clearance: ContractFile[] = [];
for (const f of files) {
// Signature images are baked into the contract PDF — don't list them here.
if (f.code === "contract") contract.push(f);
else if (f.code.startsWith("signature_")) continue;
// The generated contract PDF lives in the contract list / home rows, not
// here. Signature images are baked into that PDF — skip both.
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
else clearance.push(f);
}
return [
{ key: "contract", title: "Contract document", files: contract },
{ key: "profile", title: "Profile documents", files: profile },
{ key: "clearance", title: "Clearance documents", files: clearance },
].filter((g) => g.files.length > 0);
@@ -935,12 +933,10 @@ const KEY_FACT_ACCENT: Record<string, string> = {
// Per-section accent + icon for the Documents tab groups.
const DOC_GROUP_ACCENT: Record<string, string> = {
contract: GREEN,
profile: "#2B6CB0",
clearance: "#C77F09",
};
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
contract: FileSignature,
profile: FileText,
clearance: Upload,
};

View File

@@ -35,6 +35,7 @@ import type { Freight } from "@edr/types";
import { usePagination } from "@edr/ui-common";
import {
BORDER,
ContractDocButton,
ContractStatusBadge,
GREEN,
INK,
@@ -493,7 +494,11 @@ export default function ContractsList() {
<ContractStatusBadge status={c.status} />
</Table.Td>
<Table.Td>
<Group justify="flex-end">
<Group justify="flex-end" gap={8} wrap="nowrap">
<ContractDocButton
contract={c}
onClick={(e) => e.stopPropagation()}
/>
<Button
size="compact-sm"
radius="md"

View File

@@ -295,21 +295,28 @@ export default function NewContractPage() {
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
// size (+ optional commodity); bulk: a single commodity row.
// GENERAL contracts carry a quantity cap (draw-down); ONE_TIME does not.
const isGeneral = data.contractKind === "general_contract";
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
? data.enabledContainerSizes.map((size) => ({
containerSize: size,
cargoTypeId: data.cargoCommodityId || undefined,
quantityCap:
isGeneral && data.containerSizeCaps[size]
? data.containerSizeCaps[size]
: undefined,
}))
: [
{
cargoTypeId: data.cargoTypePath?.[1] || undefined,
cargoFreeText: data.cargoFreeText || undefined,
quantityCap:
isGeneral && data.bulkQuantityCap ? data.bulkQuantityCap : undefined,
},
];
// Routes — pure origin→destination lanes, no quantity. Route #1 is primary;
// extras only apply to GENERAL contracts.
const isGeneral = data.contractKind === "general_contract";
const routes: Freight.CreateContractRouteInputDto[] = [
{
originYardId: data.originYard,

View File

@@ -48,6 +48,7 @@ import {
shipmentStepFields,
} from "./new-shipment-form/schema";
import { computeShipmentTotal } from "./new-shipment-form/total";
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
type ShipmentForm = ReturnType<
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
@@ -458,6 +459,7 @@ function CargoStep({
description="Enter the quantity and per-container details for each size in your contract scope."
/>
<Stack gap={18}>
<ContractCapacityNotice contractId={contract.id} isContainer />
{lines.map((line, index) => (
<ContainerLineEditor
key={line.containerSize}
@@ -486,6 +488,7 @@ function CargoStep({
description="Enter the amount you are shipping for this booking."
/>
<Stack gap={14}>
<ContractCapacityNotice contractId={contract.id} isContainer={false} />
<Controller
name="cargoWeightTons"
control={form.control}

View File

@@ -1,6 +1,10 @@
import { Box, Group, Paper, Text } from "@mantine/core";
import { Box, Group, Paper, Text, Tooltip } from "@mantine/core";
import { FileText } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { fileViewUrl } from "@/constants/apiConfig";
// Brand palette (mirrors the booking form's shared constants).
export const INK = "#10202F";
@@ -226,3 +230,52 @@ export function formatQuantity(
if (unit === "PER_ITEM") return `${rounded} items`;
return `${rounded} tons`;
}
/** The generated contract PDF (file with code "contract"), if present. */
export function contractPdfFile(
contract: Pick<Freight.IContract, "files">,
): NonNullable<Freight.IContract["files"]>[number] | undefined {
return (contract.files ?? []).find((f) => f.code === "contract");
}
/**
* Icon button that opens the generated contract PDF in a new tab. Renders
* nothing when the contract has not been generated yet, so it's safe to drop
* into any contract row (list table, home recents, etc).
*/
export function ContractDocButton({
contract,
onClick,
}: {
contract: Pick<Freight.IContract, "files">;
/** Stop row-click propagation when the button lives inside a clickable row. */
onClick?: (e: React.MouseEvent) => void;
}) {
const file = contractPdfFile(contract);
if (!file) return null;
return (
<Tooltip label="Contract document">
<Box
component="a"
href={fileViewUrl(file.id)}
target="_blank"
rel="noreferrer"
onClick={onClick}
aria-label="Open contract document"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: 8,
border: `1px solid ${BORDER}`,
color: GREEN_DARK,
flexShrink: 0,
}}
>
<FileText size={16} />
</Box>
</Tooltip>
);
}

View File

@@ -151,9 +151,14 @@ export const contractFormSchema = z
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
// Optional commodity label for the contract PDF (container scope).
cargoCommodityId: z.string().default(""),
// GENERAL only: per-size container quantity cap (total bookable over the
// validity window). Keyed by size; 0/undefined = uncapped.
containerSizeCaps: z.record(z.string(), z.number().nonnegative()).default({}),
// Bulk scope: the cargo type path (group → commodity).
cargoTypePath: z.array(z.string()).default([]),
cargoFreeText: z.string().default(""),
// GENERAL only: total bulk tons/items bookable. 0 = uncapped.
bulkQuantityCap: z.number().nonnegative().default(0),
// Contract-level billing flags.
isHazardous: z.boolean().default(false),
isRefrigerated: z.boolean().default(false),
@@ -261,8 +266,10 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
cargoType: "container",
enabledContainerSizes: ["20ft"],
cargoCommodityId: "",
containerSizeCaps: {},
cargoTypePath: [],
cargoFreeText: "",
bulkQuantityCap: 0,
isHazardous: false,
isRefrigerated: false,
@@ -298,8 +305,10 @@ export const contractStepFields: Record<
"cargoType",
"enabledContainerSizes",
"cargoCommodityId",
"containerSizeCaps",
"cargoTypePath",
"cargoFreeText",
"bulkQuantityCap",
"isHazardous",
"isRefrigerated",
"originYard",

View File

@@ -1,7 +1,17 @@
import { useEffect, useMemo, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, Snowflake } from "lucide-react";
import { Box, Group, MultiSelect, Select, Skeleton, Stack, Switch, Text } from "@mantine/core";
import {
Box,
Group,
MultiSelect,
NumberInput,
Select,
Skeleton,
Stack,
Switch,
Text,
} from "@mantine/core";
import type { Freight } from "@edr/types";
import {
ContractFormInputValues,
@@ -46,6 +56,8 @@ export function Step3CargoScope({
const cargoType = form.watch("cargoType");
const cargoTypePath = form.watch("cargoTypePath") ?? [];
const parentId = cargoTypePath[0];
const isGeneral = form.watch("contractKind") === "general_contract";
const enabledSizes = form.watch("enabledContainerSizes") ?? [];
// Reset the commodity child only when the parent group really changes.
const prevParentIdRef = useRef<string | undefined>(parentId);
@@ -233,6 +245,62 @@ export function Step3CargoScope({
</Stack>
)}
{/* GENERAL contract quantity cap (draw-down ceiling). */}
{isGeneral && (
<Box>
<StepLabel>Booking quantity cap (optional)</StepLabel>
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
Total quantity bookable across all shipments under this contract.
Customers / GL can book repeatedly until it is reached. Leave 0 for
unlimited.
</Text>
{cargoType === "container" ? (
<Group gap={12} grow>
{enabledSizes.length === 0 ? (
<Text fz={13} c="dimmed">
Select container sizes above to set their caps.
</Text>
) : (
enabledSizes.map((size) => (
<Controller
key={size}
name={`containerSizeCaps.${size}`}
control={form.control}
render={({ field }) => (
<NumberInput
label={`${size} cap (containers)`}
placeholder="0 = unlimited"
min={0}
value={field.value ?? 0}
onChange={(v) => field.onChange(Number(v) || 0)}
radius={10}
styles={fieldStyles}
/>
)}
/>
))
)}
</Group>
) : (
<Controller
name="bulkQuantityCap"
control={form.control}
render={({ field }) => (
<NumberInput
label="Total cap (tons / items)"
placeholder="0 = unlimited"
min={0}
value={field.value ?? 0}
onChange={(v) => field.onChange(Number(v) || 0)}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
</Box>
)}
{/* Shared billing flags. */}
<Box>
<StepLabel>Cargo handling</StepLabel>

View File

@@ -0,0 +1,64 @@
import { useQuery } from "@tanstack/react-query";
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
import { Boxes } from "lucide-react";
import { contractsService } from "@/services/contracts.service";
/**
* Remaining draw-down capacity for a GENERAL contract — how many more
* containers / tons may still be booked. Renders nothing for uncapped or
* ONE_TIME contracts. When any line is full, shows a red "no capacity" alert.
*/
export function ContractCapacityNotice({
contractId,
isContainer,
}: {
contractId: string;
isContainer: boolean;
}) {
const { data: lines = [] } = useQuery({
queryKey: ["contract-capacity", contractId],
queryFn: () => contractsService.getCapacity(contractId),
enabled: !!contractId,
});
if (lines.length === 0) return null;
const allFull = lines.every((l) => l.remaining === 0);
const unit = isContainer ? "" : " tons";
return (
<Alert
color={allFull ? "red" : "edr-green"}
variant="light"
radius="md"
icon={<Boxes size={16} />}
title={allFull ? "Contract capacity reached" : "Remaining contract capacity"}
>
{allFull ? (
<Text fz={13}>
This contract has been fully booked. No further shipments can be
created against it.
</Text>
) : (
<Stack gap={6} mt={4}>
{lines.map((l, i) => (
<Group key={i} justify="space-between" wrap="nowrap">
<Text fz={13}>
{l.containerSize ?? "Bulk"}
</Text>
<Badge
color={l.remaining === 0 ? "red" : "edr-green"}
variant="light"
radius="sm"
>
{l.remaining}
{unit} of {l.cap} left
</Badge>
</Group>
))}
</Stack>
)}
</Alert>
);
}

View File

@@ -263,6 +263,12 @@ export const contractsService = {
return data.data ?? data;
},
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
const { data } = await client.get(C.CAPACITY(id));
return (data.data ?? data) as Freight.ContractCapacityLine[];
},
/** Customer uploads the duty/tax payment slip (doc-triggers DUTY_TAX_PAID). */
uploadDutySlip: async (
bookingId: string,