mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
implement Global Logistics booking process for customs contracts and enhance contract document handling
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -191,15 +192,20 @@ export class ContractBookingService {
|
|||||||
*/
|
*/
|
||||||
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
||||||
if (contract.customsClearingEnabled) {
|
if (contract.customsClearingEnabled) {
|
||||||
// Path B — the customer creates the booking once GL has finalized the
|
// Path B — Global Logistics creates the booking ON BEHALF OF the customer
|
||||||
// pre-booking clearance (GL "create booking" was removed; clearance ends
|
// once GL has finalized the pre-booking clearance. The customer never
|
||||||
// at finalize and hands the booking back to the customer).
|
// books a customs contract himself.
|
||||||
|
if (!isGlActor) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Contract clearance is not ready for booking yet.',
|
'Contract clearance is not ready for booking yet.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return isGlActor ? 'GL_ET' : 'CUSTOMER';
|
return 'GL_ET';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path A — customer (or staff) once the contract is executed.
|
// Path A — customer (or staff) once the contract is executed.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -220,9 +220,55 @@ export class ContractsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach the company profile's onboarding / business-license documents to the
|
||||||
|
// contract by reference. The separate "Documents" intake step was removed —
|
||||||
|
// the profile documents are simply carried onto every contract automatically.
|
||||||
|
await this.attachProfileDocuments(contract.id, companyProfileId);
|
||||||
|
|
||||||
return { contract: await this.findById(contract.id), warnings };
|
return { contract: await this.findById(contract.id), warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a company profile's stored business-license / onboarding documents onto
|
||||||
|
* a contract by reference (no byte re-upload). Codes are slugged from each
|
||||||
|
* document name so they group under "Profile documents" on the contract detail
|
||||||
|
* page. No-op when the contract has no profile or the profile has no documents.
|
||||||
|
*/
|
||||||
|
private async attachProfileDocuments(
|
||||||
|
contractId: string,
|
||||||
|
companyProfileId: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!companyProfileId) return;
|
||||||
|
const profile = await this.dataSource
|
||||||
|
.getRepository(CompanyProfile)
|
||||||
|
.findOne({ where: { id: companyProfileId } });
|
||||||
|
const docs = profile?.businessLicenseFiles ?? [];
|
||||||
|
if (docs.length === 0) return;
|
||||||
|
|
||||||
|
const slug = (name: string) =>
|
||||||
|
name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\.[a-z0-9]+$/, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '') || 'profile_document';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.filesService.attachExistingFiles(
|
||||||
|
contractId,
|
||||||
|
'contracts',
|
||||||
|
docs.map((d, i) => ({
|
||||||
|
code: `${slug(d.name)}_${i + 1}`,
|
||||||
|
name: d.name,
|
||||||
|
url: d.url,
|
||||||
|
size: d.size,
|
||||||
|
mimeType: d.mimeType,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — the contract is still valid without the carried documents.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async persistRoutes(
|
private async persistRoutes(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
routes: CreateContractDto['routes'],
|
routes: CreateContractDto['routes'],
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ export class WagonsService {
|
|||||||
async update(id: string, dto: UpdateWagonDto): Promise<Wagon> {
|
async update(id: string, dto: UpdateWagonDto): Promise<Wagon> {
|
||||||
const wagon = await this.findById(id);
|
const wagon = await this.findById(id);
|
||||||
Object.assign(wagon, dto);
|
Object.assign(wagon, dto);
|
||||||
|
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
|
||||||
|
// TypeORM otherwise re-derives `current_yard_id` from the STALE relation
|
||||||
|
// object (the old yard) on save and silently reverts the change. Drop the
|
||||||
|
// relation so the scalar `currentYardId` wins.
|
||||||
|
if (dto.currentYardId !== undefined) {
|
||||||
|
wagon.currentYard = null;
|
||||||
|
}
|
||||||
await this.wagonRepo.save(wagon);
|
await this.wagonRepo.save(wagon);
|
||||||
// Re-read with the relation so the response reflects the new yard label
|
// Re-read with the relation so the response reflects the new yard label
|
||||||
// instead of the stale relation object loaded before the assign.
|
// instead of the stale relation object loaded before the assign.
|
||||||
|
|||||||
@@ -11,19 +11,25 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Modal,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
CheckCircle2,
|
||||||
Container as ContainerIcon,
|
Container as ContainerIcon,
|
||||||
FileText,
|
FileText,
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
Plus,
|
||||||
|
Receipt,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -36,6 +42,11 @@ import {
|
|||||||
useContractMutations,
|
useContractMutations,
|
||||||
} from "@/hooks/contracts/useContracts";
|
} from "@/hooks/contracts/useContracts";
|
||||||
import { Boxes } from "lucide-react";
|
import { Boxes } from "lucide-react";
|
||||||
|
import {
|
||||||
|
computeGlShipmentTotal,
|
||||||
|
formatRateUnit,
|
||||||
|
type GlShipmentQuantities,
|
||||||
|
} from "./gl-booking-form/total";
|
||||||
|
|
||||||
interface UnitDraft {
|
interface UnitDraft {
|
||||||
containerNumber: string;
|
containerNumber: string;
|
||||||
@@ -73,6 +84,9 @@ export default function GlCreateBookingForm() {
|
|||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||||
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
|
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
|
||||||
|
// Price-confirm modal — GL reviews the estimate before booking on behalf of
|
||||||
|
// the customer, mirroring the portal customer flow.
|
||||||
|
const [priceOpen, setPriceOpen] = useState(false);
|
||||||
|
|
||||||
const isContainer = contract?.freightType === "CONTAINER";
|
const isContainer = contract?.freightType === "CONTAINER";
|
||||||
const routes = useMemo(
|
const routes = useMemo(
|
||||||
@@ -104,6 +118,34 @@ export default function GlCreateBookingForm() {
|
|||||||
|
|
||||||
const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? "";
|
const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? "";
|
||||||
|
|
||||||
|
// Normalized quantities for the client-side price estimate (same source the
|
||||||
|
// portal customer sees: the contract's frozen unit rates × entered qty).
|
||||||
|
const quantities: GlShipmentQuantities = useMemo(
|
||||||
|
() => ({
|
||||||
|
isContainer,
|
||||||
|
containers: containerLines.map((l) => ({
|
||||||
|
containerSize: l.containerSize,
|
||||||
|
quantity: l.units.length,
|
||||||
|
hazardousQuantity: Number(l.hazardousQuantity || 0),
|
||||||
|
reeferQuantity: Number(l.reeferQuantity || 0),
|
||||||
|
})),
|
||||||
|
bulkQuantity: bulkLines.reduce(
|
||||||
|
(s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
bulkHazardousQuantity: bulkLines.reduce(
|
||||||
|
(s, l) => s + Number(l.hazardousQuantity || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[isContainer, containerLines, bulkLines],
|
||||||
|
);
|
||||||
|
|
||||||
|
const priceTotal = useMemo(
|
||||||
|
() => (contract ? computeGlShipmentTotal(contract, quantities) : null),
|
||||||
|
[contract, quantities],
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -119,7 +161,7 @@ export default function GlCreateBookingForm() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Contract not found"
|
title="Contract not found"
|
||||||
backTo="/dashboard/clearance"
|
backTo="/dashboard/contracts/clearance"
|
||||||
/>
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
@@ -233,12 +275,15 @@ export default function GlCreateBookingForm() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Create booking (GL)"
|
title="Create booking (GL)"
|
||||||
subtitle={`Enter the shipment details on behalf of the customer for contract ${contract.reference}.`}
|
subtitle={`Enter the shipment details on behalf of the customer for contract ${contract.reference}.`}
|
||||||
backTo={`/dashboard/clearance/${contract.id}`}
|
backTo={`/dashboard/contracts/clearance/${contract.id}`}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: "Document Clearance", href: "/dashboard/clearance" },
|
{
|
||||||
|
label: "Document Clearance",
|
||||||
|
href: "/dashboard/contracts/clearance",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: contract.reference,
|
label: contract.reference,
|
||||||
href: `/dashboard/clearance/${contract.id}`,
|
href: `/dashboard/contracts/clearance/${contract.id}`,
|
||||||
},
|
},
|
||||||
{ label: "Create booking" },
|
{ label: "Create booking" },
|
||||||
]}
|
]}
|
||||||
@@ -578,20 +623,120 @@ export default function GlCreateBookingForm() {
|
|||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => navigate(`/dashboard/clearance/${contract.id}`)}
|
onClick={() =>
|
||||||
|
navigate(`/dashboard/contracts/clearance/${contract.id}`)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Receipt size={16} />}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
loading={mutations.createBooking.isPending}
|
onClick={() => setPriceOpen(true)}
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
>
|
||||||
Create booking
|
Review price & book
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{/* Price-confirm — GL reviews the estimate, then books on behalf of the
|
||||||
|
customer. The server recomputes the authoritative total on submit. */}
|
||||||
|
<Modal
|
||||||
|
opened={priceOpen}
|
||||||
|
onClose={() => {
|
||||||
|
if (!mutations.createBooking.isPending) setPriceOpen(false);
|
||||||
|
}}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size="lg"
|
||||||
|
title={
|
||||||
|
<Group gap={10}>
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||||
|
<Receipt size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text fw={800} fz={16}>
|
||||||
|
Confirm shipment price
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
Booking on behalf of the customer for {contract.reference}.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{priceTotal ? (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Paper withBorder radius={16} p="lg">
|
||||||
|
<Stack gap={10}>
|
||||||
|
{priceTotal.lines.map((line, i) => (
|
||||||
|
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{line.label}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{line.quantity.toLocaleString()} ×{" "}
|
||||||
|
{line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "}
|
||||||
|
{formatRateUnit(line.unit)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Text fz="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{line.amount.toLocaleString()} {priceTotal.currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
{priceTotal.lines.length === 0 && (
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
No priced lines — check the cargo details.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Group justify="space-between" align="flex-end">
|
||||||
|
<Text
|
||||||
|
fz="xs"
|
||||||
|
fw={700}
|
||||||
|
tt="uppercase"
|
||||||
|
c="edr-green"
|
||||||
|
style={{ letterSpacing: "0.06em" }}
|
||||||
|
>
|
||||||
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text fw={800} fz={28}>
|
||||||
|
{priceTotal.total.toLocaleString()}{" "}
|
||||||
|
<Text span fz={16} fw={700} c="dimmed">
|
||||||
|
{priceTotal.currency}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="xs">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<X size={16} />}
|
||||||
|
onClick={() => setPriceOpen(false)}
|
||||||
|
disabled={mutations.createBooking.isPending}
|
||||||
|
>
|
||||||
|
Back to edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
|
loading={mutations.createBooking.isPending}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Confirm & book
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
export interface GlShipmentTotalLine {
|
||||||
|
label: string;
|
||||||
|
unitPrice: number;
|
||||||
|
unit: Freight.ContractRateUnit | string;
|
||||||
|
quantity: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlShipmentTotal {
|
||||||
|
currency: string;
|
||||||
|
lines: GlShipmentTotalLine[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A normalized view of the form quantities, freight-shape agnostic. */
|
||||||
|
export interface GlShipmentQuantities {
|
||||||
|
isContainer: boolean;
|
||||||
|
/** Container lines: size + total qty + hazardous/reefer qty. */
|
||||||
|
containers: Array<{
|
||||||
|
containerSize: string;
|
||||||
|
quantity: number;
|
||||||
|
hazardousQuantity: number;
|
||||||
|
reeferQuantity: number;
|
||||||
|
}>;
|
||||||
|
/** Bulk: tons (or item count) + hazardous qty. */
|
||||||
|
bulkQuantity: number;
|
||||||
|
bulkHazardousQuantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the booking total client-side from the contract's frozen unit rates ×
|
||||||
|
* the quantities GL enters. Mirrors the portal customer estimate
|
||||||
|
* (new-shipment-form/total.ts) — the server recomputes the authoritative total
|
||||||
|
* on submit. Shown in the price-confirm modal before GL books on behalf of the
|
||||||
|
* customer.
|
||||||
|
*/
|
||||||
|
export function computeGlShipmentTotal(
|
||||||
|
contract: Freight.IContract,
|
||||||
|
q: GlShipmentQuantities,
|
||||||
|
): GlShipmentTotal {
|
||||||
|
const breakdown = contract.pricingBreakdown;
|
||||||
|
const currency = breakdown?.currency ?? contract.paymentCurrency ?? "ETB";
|
||||||
|
const items = breakdown?.lineItems ?? [];
|
||||||
|
const lines: GlShipmentTotalLine[] = [];
|
||||||
|
|
||||||
|
const rateFor = (
|
||||||
|
predicate: (i: Freight.ContractUnitRateLineItem) => boolean,
|
||||||
|
) => items.find(predicate);
|
||||||
|
|
||||||
|
if (q.isContainer) {
|
||||||
|
let hazardTotalQty = 0;
|
||||||
|
let reeferTotalQty = 0;
|
||||||
|
|
||||||
|
for (const line of q.containers) {
|
||||||
|
const qty = line.quantity;
|
||||||
|
if (qty <= 0) continue;
|
||||||
|
const rate =
|
||||||
|
rateFor(
|
||||||
|
(i) =>
|
||||||
|
i.containerSize === line.containerSize &&
|
||||||
|
i.unit === "per_container" &&
|
||||||
|
!i.conditionalOn,
|
||||||
|
) ?? rateFor((i) => i.containerSize === line.containerSize);
|
||||||
|
if (rate) {
|
||||||
|
lines.push({
|
||||||
|
label: rate.label,
|
||||||
|
unitPrice: rate.unitPrice,
|
||||||
|
unit: rate.unit,
|
||||||
|
quantity: qty,
|
||||||
|
amount: rate.unitPrice * qty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
hazardTotalQty += line.hazardousQuantity;
|
||||||
|
reeferTotalQty += line.reeferQuantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contract.isHazardous && hazardTotalQty > 0) {
|
||||||
|
const hz = rateFor((i) => i.conditionalOn === "is_hazardous");
|
||||||
|
if (hz) {
|
||||||
|
lines.push({
|
||||||
|
label: hz.label,
|
||||||
|
unitPrice: hz.unitPrice,
|
||||||
|
unit: hz.unit,
|
||||||
|
quantity: hazardTotalQty,
|
||||||
|
amount: hz.unitPrice * hazardTotalQty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contract.isReefer && reeferTotalQty > 0) {
|
||||||
|
const rf = rateFor((i) => i.conditionalOn === "is_reefer");
|
||||||
|
if (rf) {
|
||||||
|
lines.push({
|
||||||
|
label: rf.label,
|
||||||
|
unitPrice: rf.unitPrice,
|
||||||
|
unit: rf.unit,
|
||||||
|
quantity: reeferTotalQty,
|
||||||
|
amount: rf.unitPrice * reeferTotalQty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const qty = q.bulkQuantity;
|
||||||
|
const rate =
|
||||||
|
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
|
||||||
|
if (rate && qty > 0) {
|
||||||
|
lines.push({
|
||||||
|
label: rate.label,
|
||||||
|
unitPrice: rate.unitPrice,
|
||||||
|
unit: rate.unit,
|
||||||
|
quantity: qty,
|
||||||
|
amount: rate.unitPrice * qty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = lines.reduce((s, l) => s + l.amount, 0);
|
||||||
|
return { currency, lines, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable label for a contract unit-rate's charge unit. */
|
||||||
|
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
per_container: "container",
|
||||||
|
per_ton: "ton",
|
||||||
|
per_item: "item",
|
||||||
|
per_km: "km",
|
||||||
|
flat: "flat",
|
||||||
|
};
|
||||||
|
return map[unit] ?? unit.replace(/_/g, " ").replace(/^per /, "");
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
Inbox,
|
Inbox,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
PackagePlus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -293,15 +295,33 @@ export default function ContractClearanceListPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "go",
|
id: "go",
|
||||||
size: 56,
|
size: 150,
|
||||||
cell: () => (
|
cell: ({ row }) =>
|
||||||
<Group justify="flex-end" pr="xs">
|
row.original.ready ? (
|
||||||
<ChevronRight size={16} className="text-muted-foreground" />
|
<Group justify="flex-end" pr="xs">
|
||||||
</Group>
|
<Button
|
||||||
),
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<PackagePlus size={14} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(
|
||||||
|
`/dashboard/contracts/${row.original.id}/create-booking`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Group justify="flex-end" pr="xs">
|
||||||
|
<ChevronRight size={16} className="text-muted-foreground" />
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -21,7 +20,6 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
PackagePlus,
|
|
||||||
Plus,
|
Plus,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -87,7 +85,6 @@ export function ContractClearancePanel({
|
|||||||
bare,
|
bare,
|
||||||
}: ContractClearancePanelProps) {
|
}: ContractClearancePanelProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [pending, setPending] = useState<Record<string, File>>({});
|
const [pending, setPending] = useState<Record<string, File>>({});
|
||||||
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
|
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
@@ -195,8 +192,9 @@ export function ContractClearancePanel({
|
|||||||
)}
|
)}
|
||||||
{isReady ? (
|
{isReady ? (
|
||||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
||||||
Your clearance documents are approved. You can now create a shipment
|
{customsPath
|
||||||
booking under this contract.
|
? "Your clearance documents are approved. Global Logistics will create your booking on your behalf — you will be notified when payment is due."
|
||||||
|
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : isUnderReview ? (
|
) : isUnderReview ? (
|
||||||
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
||||||
@@ -443,19 +441,6 @@ export function ContractClearancePanel({
|
|||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Clearance finalized → the customer creates the booking himself. */}
|
|
||||||
{isReady && status === "CLEARANCE_READY_FOR_BOOKING" && (
|
|
||||||
<Group justify="flex-end" mt="lg">
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<PackagePlus size={16} />}
|
|
||||||
onClick={() => navigate(`/contracts/${contractId}/bookings/new`)}
|
|
||||||
>
|
|
||||||
Create booking
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{viewer}
|
{viewer}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -205,19 +205,20 @@ export default function ContractDetailPage() {
|
|||||||
|
|
||||||
const canSign = contract.status === "CONTRACT_READY";
|
const canSign = contract.status === "CONTRACT_READY";
|
||||||
const customsPath = contract.customsClearingEnabled;
|
const customsPath = contract.customsClearingEnabled;
|
||||||
// The customer creates the booking himself on BOTH paths:
|
// Only the NON-customs (Path A) customer books himself — once the contract is
|
||||||
// - Path A (no customs): once the contract is executed after self-clearance.
|
// executed after self-clearance. Customs (Path B) bookings are created by
|
||||||
// - Path B (customs): once GL finalizes the pre-booking clearance
|
// Global Logistics on the customer's behalf, so the customer gets no booking
|
||||||
// (CLEARANCE_READY_FOR_BOOKING). GL "create booking" was removed.
|
// button on a customs contract.
|
||||||
const clearanceFinalized =
|
const clearanceFinalized =
|
||||||
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
|
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||||
contract.status === "CLEARANCE_READY_FOR_BOOKING";
|
contract.status === "CLEARANCE_READY_FOR_BOOKING";
|
||||||
const canBookShipment = customsPath
|
const canBookShipment =
|
||||||
? clearanceFinalized
|
!customsPath && PATH_A_BOOKABLE.includes(contract.status);
|
||||||
: PATH_A_BOOKABLE.includes(contract.status);
|
// Customs + clearance finalized: GL is preparing the booking — surface a
|
||||||
// The customer uploads clearance documents while in a clearance status — but
|
// status notice instead of any action.
|
||||||
// once clearance is finalized the upload step is done; the action becomes
|
const glPreparingBooking = customsPath && clearanceFinalized;
|
||||||
// "create booking" instead.
|
// The customer uploads clearance documents while in a clearance status, until
|
||||||
|
// clearance is finalized.
|
||||||
const canUploadClearance =
|
const canUploadClearance =
|
||||||
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
|
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
|
||||||
|
|
||||||
@@ -292,6 +293,17 @@ export default function ContractDetailPage() {
|
|||||||
New shipment booking
|
New shipment booking
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{glPreparingBooking && (
|
||||||
|
<Badge
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<CheckCircle2 size={14} />}
|
||||||
|
>
|
||||||
|
Global Logistics is creating your booking
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{canUploadClearance && (
|
{canUploadClearance && (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
|
|||||||
@@ -108,6 +108,21 @@ function getCustomerRowAction(
|
|||||||
icon: Upload,
|
icon: Upload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Customs (Path B), clearance finalized: Global Logistics creates the booking
|
||||||
|
// on the customer's behalf — the customer only views the contract.
|
||||||
|
if (
|
||||||
|
contract.customsClearingEnabled &&
|
||||||
|
["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
|
||||||
|
contract.status,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
label: "View",
|
||||||
|
to: `/contracts/${id}`,
|
||||||
|
primary: false,
|
||||||
|
icon: Eye,
|
||||||
|
};
|
||||||
|
}
|
||||||
const booking = getContractBookingAction(contract, bookings);
|
const booking = getContractBookingAction(contract, bookings);
|
||||||
if (booking.kind === "book") {
|
if (booking.kind === "book") {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
@@ -57,7 +57,6 @@ import {
|
|||||||
Step3CargoScope,
|
Step3CargoScope,
|
||||||
Step4Route,
|
Step4Route,
|
||||||
Step8Review,
|
Step8Review,
|
||||||
StepDocuments,
|
|
||||||
} from "./new-contract-form/steps";
|
} from "./new-contract-form/steps";
|
||||||
import { StepCard, StepHeader } from "./new-contract-form/shared";
|
import { StepCard, StepHeader } from "./new-contract-form/shared";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
@@ -261,27 +260,11 @@ export default function NewContractPage() {
|
|||||||
return active?.licenseFiles ?? [];
|
return active?.licenseFiles ?? [];
|
||||||
}, [auth.company, auth.activeCompanyProfileId]);
|
}, [auth.company, auth.activeCompanyProfileId]);
|
||||||
|
|
||||||
// The documents step validates required uploads imperatively (the requirement
|
|
||||||
// set is async-loaded), so it registers a validator we call before advancing.
|
|
||||||
const docsValidatorRef = useRef<(() => boolean) | null>(null);
|
|
||||||
|
|
||||||
async function handleContinue() {
|
async function handleContinue() {
|
||||||
const valid = await form.trigger(contractStepFields[step], {
|
const valid = await form.trigger(contractStepFields[step], {
|
||||||
shouldFocus: true,
|
shouldFocus: true,
|
||||||
});
|
});
|
||||||
if (!valid) {
|
if (!valid) return;
|
||||||
// TEMP DEBUG — surface which step-1 fields block Continue.
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn("[contract continue blocked] step", step, {
|
|
||||||
errors: JSON.parse(JSON.stringify(form.formState.errors)),
|
|
||||||
values: form.getValues(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Step 2 — Documents: every required document must be on file or uploaded.
|
|
||||||
if (step === 2 && docsValidatorRef.current && !docsValidatorRef.current()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goToStep(1);
|
goToStep(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,12 +523,8 @@ export default function NewContractPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2 — Documents. */}
|
{/* Step 2 — Documents. */}
|
||||||
|
{/* Step 2 — Review & Submit. */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<StepDocuments form={form} validatorRef={docsValidatorRef} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 3 — Review & Submit. */}
|
|
||||||
{step === 3 && (
|
|
||||||
<Step8Review
|
<Step8Review
|
||||||
form={form}
|
form={form}
|
||||||
setStep={setStep}
|
setStep={setStep}
|
||||||
|
|||||||
@@ -112,24 +112,21 @@ export default function NewShipmentPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customs (Path B) contracts can only be booked once GL has finalized the
|
// Customs (Path B) contracts are booked by Global Logistics on behalf of the
|
||||||
// pre-booking clearance. Before that, send the customer to the clearance step.
|
// customer — the customer never books one himself. Block the form entirely and
|
||||||
// (GL "create booking" was removed — the customer books once cleared.)
|
// point back to the clearance workspace.
|
||||||
const clearanceFinalized =
|
if (contract.customsClearingEnabled) {
|
||||||
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
|
|
||||||
contract.status === "CLEARANCE_READY_FOR_BOOKING" ||
|
|
||||||
contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
|
||||||
if (contract.customsClearingEnabled && !clearanceFinalized) {
|
|
||||||
return (
|
return (
|
||||||
<Box p="xl">
|
<Box p="xl">
|
||||||
<Alert color="orange" icon={<AlertCircle size={18} />} radius="md">
|
<Alert color="blue" icon={<AlertCircle size={18} />} radius="md">
|
||||||
<Text fw={700} mb="xs">
|
<Text fw={700} mb="xs">
|
||||||
Clearance not finalized yet
|
Global Logistics handles bookings for this contract
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" mb="md">
|
<Text size="sm" mb="md">
|
||||||
This contract includes customs clearance. Upload your clearance
|
This contract includes customs clearance. Upload your clearance
|
||||||
documents — once Global Logistics finalizes the clearance you can
|
documents — once Global Logistics finalizes the clearance, they
|
||||||
create your shipment booking here.
|
create the booking on your behalf and you will be notified when
|
||||||
|
payment is due.
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
|
|||||||
@@ -15,16 +15,6 @@ export const TERMINAL_BOOKING_STATUSES = [
|
|||||||
/** Path A statuses where a customer (no customs) may book against the contract. */
|
/** Path A statuses where a customer (no customs) may book against the contract. */
|
||||||
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
||||||
|
|
||||||
/**
|
|
||||||
* Path B (customs): the customer books once GL has finalized the pre-booking
|
|
||||||
* clearance. ACTIVE_SHIPMENT_IN_PROGRESS is included so GENERAL contracts can
|
|
||||||
* re-book after a prior shipment. (GL "create booking" was removed.)
|
|
||||||
*/
|
|
||||||
const PATH_B_BOOKABLE = [
|
|
||||||
"CLEARANCE_READY_FOR_BOOKING",
|
|
||||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
|
||||||
];
|
|
||||||
|
|
||||||
export type ContractBookingActionKind = "book" | "rebook" | "none";
|
export type ContractBookingActionKind = "book" | "rebook" | "none";
|
||||||
|
|
||||||
export interface ContractBookingAction {
|
export interface ContractBookingAction {
|
||||||
@@ -45,10 +35,10 @@ export function getContractBookingAction(
|
|||||||
contract: Freight.IContract,
|
contract: Freight.IContract,
|
||||||
bookings: Freight.IBooking[],
|
bookings: Freight.IBooking[],
|
||||||
): ContractBookingAction {
|
): ContractBookingAction {
|
||||||
const bookable = contract.customsClearingEnabled
|
// Customs (Path B) contracts are booked by Global Logistics on behalf of the
|
||||||
? PATH_B_BOOKABLE.includes(contract.status)
|
// customer — the customer never gets a Book button for them.
|
||||||
: PATH_A_BOOKABLE.includes(contract.status);
|
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
|
||||||
if (!bookable) return { kind: "none", to: "" };
|
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
|
||||||
|
|
||||||
const to = `/contracts/${contract.id}/bookings/new`;
|
const to = `/contracts/${contract.id}/bookings/new`;
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import * as z from "zod";
|
|||||||
export const CONTRACT_STEPS = [
|
export const CONTRACT_STEPS = [
|
||||||
{ id: 0, label: "Setup", short: "Setup" },
|
{ id: 0, label: "Setup", short: "Setup" },
|
||||||
{ id: 1, label: "Cargo & Route", short: "Cargo & Route" },
|
{ id: 1, label: "Cargo & Route", short: "Cargo & Route" },
|
||||||
{ id: 2, label: "Documents", short: "Documents" },
|
{ id: 2, label: "Review & Submit", short: "Review" },
|
||||||
{ id: 3, label: "Review & Submit", short: "Review" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const OPERATION_TYPES = [
|
export const OPERATION_TYPES = [
|
||||||
@@ -331,8 +330,7 @@ export const contractStepFields: Record<
|
|||||||
"extraRoutes",
|
"extraRoutes",
|
||||||
"estimatedShipmentDate",
|
"estimatedShipmentDate",
|
||||||
],
|
],
|
||||||
// Step 2 — Documents.
|
// Step 2 — Review & Submit. (The separate Documents step was removed — the
|
||||||
2: ["documents"],
|
// company profile documents are attached to the contract automatically.)
|
||||||
// Step 3 — Review & Submit.
|
2: ["notes"],
|
||||||
3: ["notes"],
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user