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,