mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 01:55:41 +00:00
Merge branch 'freight_feature/contrat' of github.com:Tria-plc/edr-platform into freight_feature/contrat
This commit is contained in:
@@ -34,7 +34,17 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
|
|||||||
async findById(id: string): Promise<BookingRequest | null> {
|
async findById(id: string): Promise<BookingRequest | null> {
|
||||||
return this.repository.findOne({
|
return this.repository.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: { contract: true },
|
// Load the contract with the bits the detail page surfaces: customer
|
||||||
|
// (company), service type (mile/customs flags), routes (with yard labels)
|
||||||
|
// and cargo scope.
|
||||||
|
relations: {
|
||||||
|
contract: {
|
||||||
|
company: true,
|
||||||
|
serviceType: true,
|
||||||
|
routes: { originYard: true, destinationYard: true },
|
||||||
|
cargoScope: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import * as fs from "fs";
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as Handlebars from "handlebars";
|
import * as Handlebars from "handlebars";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ClientAction,
|
ClientAction,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
} from "@edr/payment-providers";
|
} from "@edr/payment-providers";
|
||||||
import {
|
import {
|
||||||
|
Freight,
|
||||||
PaymentService as PaymentServiceEnum,
|
PaymentService as PaymentServiceEnum,
|
||||||
PaymentReferenceType,
|
PaymentReferenceType,
|
||||||
PaymentIntentSnapshot,
|
PaymentIntentSnapshot,
|
||||||
@@ -463,30 +465,37 @@ export class PaymentService {
|
|||||||
failureCode?: string;
|
failureCode?: string;
|
||||||
failureMessage?: string;
|
failureMessage?: string;
|
||||||
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
|
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
|
||||||
console.log(`Received payment event: ${JSON.stringify(event)}`);
|
|
||||||
if (event.eventType === "payment.succeeded") {
|
if (event.eventType === "payment.succeeded") {
|
||||||
|
console.log(`Payment succeeded event received for reference ${event.referenceId}`);
|
||||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
|
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
|
||||||
if (!intent) {
|
if (!intent) {
|
||||||
|
console.warn(`No local intent found for reference ${event.referenceId}`);
|
||||||
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||||
}
|
}
|
||||||
console.log(`Processing payment succeeded event for intent: }`,intent);
|
|
||||||
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
||||||
providerTxnId: event.providerTxnId,
|
providerTxnId: event.providerTxnId,
|
||||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||||
notify: true,
|
notify: true,
|
||||||
});
|
});
|
||||||
console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
console.log(`Payment intent ${intent.id} marked as succeeded (alreadyFinalized=${alreadyFinalized})`);
|
||||||
|
|
||||||
// When the intent references a booking, flip the booking itself paid.
|
// The invoice the intent settled is the authority on what was paid for.
|
||||||
// refId holds the booking id (the domain reference the intent opened with).
|
// Its `paymentId` links 1:1 to this intent; when its source is a booking,
|
||||||
if (intent.referenceType === PaymentReferenceType.BOOKING) {
|
// `sourceId` holds that booking id — flip the booking itself paid.
|
||||||
|
const invoice = await this.datasource.manager.findOneBy(Invoice, {
|
||||||
|
paymentId: intent.id,
|
||||||
|
});
|
||||||
|
console.log(`Invoice lookup for payment intent ${intent.id} returned invoice ${invoice?.id} (source=${invoice?.source}, sourceId=${invoice?.sourceId})`);
|
||||||
|
if (invoice?.source === Freight.InvoiceSource.Booking) {
|
||||||
|
console.log(`Marking booking ${invoice.sourceId} as PAID due to invoice ${invoice.id} settlement`);
|
||||||
await this.datasource.manager.update(
|
await this.datasource.manager.update(
|
||||||
Booking,
|
Booking,
|
||||||
{ id: intent.refId },
|
{ id: invoice.sourceId },
|
||||||
{ status: "PAID", paymentStatus: "PAID" },
|
{ status: "PAID", paymentStatus: "PAID" },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
|
||||||
return { processed: true, alreadyFinalized };
|
return { processed: true, alreadyFinalized };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
FileCheck,
|
||||||
|
FileText,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Package,
|
||||||
|
Phone,
|
||||||
|
Ship,
|
||||||
|
Truck,
|
||||||
|
User,
|
||||||
|
Warehouse,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Badge, Box, Divider, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
|
|
||||||
|
type ReqContract = NonNullable<Freight.IBookingRequest["contract"]>;
|
||||||
|
|
||||||
|
interface InfoRowProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Icon size={15} color="var(--mantine-color-gray-5)" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
|
||||||
|
{value || "—"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRows({ rows }: { rows: InfoRowProps[] }) {
|
||||||
|
const visible = rows.filter((r) => r.value);
|
||||||
|
if (visible.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No details available.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{visible.map((row, i) => (
|
||||||
|
<div key={row.label}>
|
||||||
|
{i > 0 && <Divider color="var(--mantine-color-gray-2)" />}
|
||||||
|
<InfoRow {...row} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer (company) on the request's contract. */
|
||||||
|
export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) {
|
||||||
|
const company = contract?.company;
|
||||||
|
if (!company) {
|
||||||
|
return (
|
||||||
|
<SectionCard icon={Building2} title="Customer" accent="blue">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No customer linked to this request.
|
||||||
|
</Text>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
icon={Building2}
|
||||||
|
title="Customer"
|
||||||
|
subtitle={company.name ?? undefined}
|
||||||
|
accent="blue"
|
||||||
|
>
|
||||||
|
<InfoRows
|
||||||
|
rows={[
|
||||||
|
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||||
|
{ icon: Mail, label: "Email", value: company.email },
|
||||||
|
{ icon: Phone, label: "Phone", value: company.phone },
|
||||||
|
{ icon: MapPin, label: "Address", value: company.address },
|
||||||
|
{ icon: User, label: "Contact", value: company.contactPersonName },
|
||||||
|
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtDate = (iso?: string | null) =>
|
||||||
|
iso
|
||||||
|
? new Intl.DateTimeFormat("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(iso))
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const titleCase = (s?: string | null) =>
|
||||||
|
s ? s.charAt(0) + s.slice(1).toLowerCase() : "—";
|
||||||
|
|
||||||
|
/** Contract identity + commercial terms. */
|
||||||
|
export function RequestContractSummaryCard({
|
||||||
|
contract,
|
||||||
|
}: {
|
||||||
|
contract?: ReqContract | null;
|
||||||
|
}) {
|
||||||
|
if (!contract) return null;
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
icon={FileText}
|
||||||
|
title="Contract"
|
||||||
|
subtitle={contract.reference}
|
||||||
|
accent="grape"
|
||||||
|
>
|
||||||
|
<InfoRows
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
label: "Kind",
|
||||||
|
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Package,
|
||||||
|
label: "Cargo",
|
||||||
|
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
|
||||||
|
},
|
||||||
|
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
|
||||||
|
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
|
||||||
|
{
|
||||||
|
icon: FileCheck,
|
||||||
|
label: "Customs",
|
||||||
|
value: contract.customsClearingEnabled
|
||||||
|
? "Included (Global Logistics)"
|
||||||
|
: "Not included",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
label: "Valid until",
|
||||||
|
value: contract.contractValidUntil
|
||||||
|
? fmtDate(contract.contractValidUntil)
|
||||||
|
: "Not active yet",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Routes + cargo scope of the contract. */
|
||||||
|
export function RequestRouteCargoCard({
|
||||||
|
contract,
|
||||||
|
}: {
|
||||||
|
contract?: ReqContract | null;
|
||||||
|
}) {
|
||||||
|
const routes = contract?.routes ?? [];
|
||||||
|
const cargo = contract?.cargoScope ?? [];
|
||||||
|
const isContainer = contract?.freightType === "CONTAINER";
|
||||||
|
return (
|
||||||
|
<SectionCard icon={MapPin} title="Route & cargo" accent="teal">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} mb={6} tt="uppercase">
|
||||||
|
Routes
|
||||||
|
</Text>
|
||||||
|
{routes.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No routes recorded.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap={6}>
|
||||||
|
{routes.map((r) => (
|
||||||
|
<Group key={r.id} gap={8} wrap="nowrap">
|
||||||
|
<MapPin size={14} color="var(--mantine-color-gray-5)" />
|
||||||
|
<Text size="sm" fw={500} truncate>
|
||||||
|
{r.originYard?.label ?? r.originYardId} →{" "}
|
||||||
|
{r.destinationYard?.label ?? r.destinationYardId}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} mb={6} tt="uppercase">
|
||||||
|
Cargo scope
|
||||||
|
</Text>
|
||||||
|
{cargo.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No cargo scope recorded.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
{cargo.map((c) => (
|
||||||
|
<Badge
|
||||||
|
key={c.id}
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<Package size={11} />}
|
||||||
|
>
|
||||||
|
{c.containerSize ??
|
||||||
|
c.cargoFreeText ??
|
||||||
|
(isContainer ? "Container" : "Bulk commodity")}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Service type — what the contracted service bundles (rail-only vs logistics/customs). */
|
||||||
|
export function RequestServiceTypeCard({
|
||||||
|
contract,
|
||||||
|
}: {
|
||||||
|
contract?: ReqContract | null;
|
||||||
|
}) {
|
||||||
|
const st = contract?.serviceType;
|
||||||
|
if (!st) return null;
|
||||||
|
|
||||||
|
const firstMile = st.includesFirstMile ?? false;
|
||||||
|
const lastMile = st.includesLastMile ?? false;
|
||||||
|
const customs = st.includesCustoms ?? false;
|
||||||
|
const railOnly = !firstMile && !lastMile && !customs;
|
||||||
|
|
||||||
|
const chips: Array<{ label: string; color: string; icon: LucideIcon }> = [];
|
||||||
|
if (railOnly) chips.push({ label: "Rail only", color: "blue", icon: Ship });
|
||||||
|
if (firstMile)
|
||||||
|
chips.push({ label: "First-mile pickup", color: "teal", icon: Truck });
|
||||||
|
if (lastMile)
|
||||||
|
chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse });
|
||||||
|
if (customs)
|
||||||
|
chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
icon={Ship}
|
||||||
|
title="Service"
|
||||||
|
subtitle={st.serviceName}
|
||||||
|
accent="indigo"
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
{chips.map((c) => (
|
||||||
|
<Badge
|
||||||
|
key={c.label}
|
||||||
|
variant="light"
|
||||||
|
color={c.color}
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<c.icon size={11} />}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
{st.description ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{st.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -289,16 +289,18 @@ export default function BookingRequestDetailPage() {
|
|||||||
booking={booking}
|
booking={booking}
|
||||||
mutations={mutations}
|
mutations={mutations}
|
||||||
/>
|
/>
|
||||||
<Button
|
{booking.customsClearingEnabled && (
|
||||||
fullWidth
|
<Button
|
||||||
variant="default"
|
fullWidth
|
||||||
leftSection={<Milestone size={16} />}
|
variant="default"
|
||||||
onClick={() =>
|
leftSection={<Milestone size={16} />}
|
||||||
navigate(`/dashboard/bookings/${booking.id}/milestones`)
|
onClick={() =>
|
||||||
}
|
navigate(`/dashboard/bookings/${booking.id}/milestones`)
|
||||||
>
|
}
|
||||||
View clearance milestones
|
>
|
||||||
</Button>
|
View clearance milestones
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{showContractButton && (
|
{showContractButton && (
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -24,6 +25,12 @@ import type { Freight } from "@edr/types";
|
|||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
|
import {
|
||||||
|
RequestCustomerCard,
|
||||||
|
RequestContractSummaryCard,
|
||||||
|
RequestRouteCargoCard,
|
||||||
|
RequestServiceTypeCard,
|
||||||
|
} from "@/components/contracts/detail/RequestDetailCards";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
|
||||||
const fmtDate = (iso?: string | null) =>
|
const fmtDate = (iso?: string | null) =>
|
||||||
@@ -167,49 +174,67 @@ export default function ShipmentRequestDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionCard icon={CalendarDays} title="Requested shipment">
|
<Grid gap="lg">
|
||||||
<Stack gap="sm">
|
{/* LEFT — the request itself + route/cargo scope */}
|
||||||
<Group justify="space-between">
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
<Text size="sm" c="dimmed">
|
<Stack gap="lg">
|
||||||
Preferred date (informational)
|
<SectionCard icon={CalendarDays} title="Requested shipment">
|
||||||
</Text>
|
<Stack gap="sm">
|
||||||
<Text size="sm" fw={600}>
|
<Group justify="space-between">
|
||||||
{fmtDate(request.scheduledDate)}
|
<Text size="sm" c="dimmed">
|
||||||
</Text>
|
Preferred date (informational)
|
||||||
</Group>
|
</Text>
|
||||||
<Box>
|
<Text size="sm" fw={600}>
|
||||||
<Text size="sm" c="dimmed" mb={6}>
|
{fmtDate(request.scheduledDate)}
|
||||||
Quantities
|
</Text>
|
||||||
</Text>
|
</Group>
|
||||||
<Stack gap={4}>
|
<Box>
|
||||||
{lineRows(request.requestedLines ?? {}).map((l, i) => (
|
<Text size="sm" c="dimmed" mb={6}>
|
||||||
<Badge
|
Quantities
|
||||||
key={i}
|
</Text>
|
||||||
variant="light"
|
<Stack gap={4}>
|
||||||
color="edr-green"
|
{lineRows(request.requestedLines ?? {}).map((l, i) => (
|
||||||
radius="sm"
|
<Badge
|
||||||
size="lg"
|
key={i}
|
||||||
>
|
variant="light"
|
||||||
{l}
|
color="edr-green"
|
||||||
</Badge>
|
radius="sm"
|
||||||
))}
|
size="lg"
|
||||||
</Stack>
|
>
|
||||||
</Box>
|
{l}
|
||||||
{request.notes ? (
|
</Badge>
|
||||||
<Box>
|
))}
|
||||||
<Text size="sm" c="dimmed" mb={4}>
|
</Stack>
|
||||||
Customer note
|
</Box>
|
||||||
</Text>
|
{request.notes ? (
|
||||||
<Text size="sm">{request.notes}</Text>
|
<Box>
|
||||||
</Box>
|
<Text size="sm" c="dimmed" mb={4}>
|
||||||
) : null}
|
Customer note
|
||||||
{request.reviewNote ? (
|
</Text>
|
||||||
<Alert color="red" variant="light" radius="md" mt="sm">
|
<Text size="sm">{request.notes}</Text>
|
||||||
Rejected: {request.reviewNote}
|
</Box>
|
||||||
</Alert>
|
) : null}
|
||||||
) : null}
|
{request.reviewNote ? (
|
||||||
</Stack>
|
<Alert color="red" variant="light" radius="md" mt="sm">
|
||||||
</SectionCard>
|
Rejected: {request.reviewNote}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<RequestRouteCargoCard contract={request.contract} />
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* RIGHT — customer, contract + service-type context */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<RequestCustomerCard contract={request.contract} />
|
||||||
|
<RequestContractSummaryCard contract={request.contract} />
|
||||||
|
<RequestServiceTypeCard contract={request.contract} />
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Send,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
|
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
|
||||||
|
import { BORDER, GREEN, INK } from "./contract-ui";
|
||||||
|
|
||||||
|
type DocumentsValue = Record<string, File | File[] | null>;
|
||||||
|
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
||||||
|
|
||||||
|
/** Onboarding document setting code for the company's nationality. */
|
||||||
|
function documentSettingCode(nationality: string | null | undefined): string {
|
||||||
|
return nationality === "foreign"
|
||||||
|
? "company_onboarding_documents_foreign"
|
||||||
|
: "company_onboarding_documents_ethiopian";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFile(value: File | File[] | null | undefined): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
return Array.isArray(value) ? value.length > 0 : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row per distinct doc code already on the contract (latest upload). */
|
||||||
|
function dedupeLatestByCode(files: ContractFile[]): ContractFile[] {
|
||||||
|
const order: string[] = [];
|
||||||
|
const latest = new Map<string, ContractFile>();
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
|
||||||
|
if (!latest.has(f.code)) order.push(f.code);
|
||||||
|
latest.set(f.code, f);
|
||||||
|
}
|
||||||
|
return order.map((c) => latest.get(c)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit-and-resubmit view for a contract staff returned with CHANGES_REQUESTED.
|
||||||
|
* The customer reviews the request, updates their documents (business license,
|
||||||
|
* TIN, national ID, passport, … — driven by the company onboarding setting),
|
||||||
|
* then resubmits. Documents already on the contract are shown as "on file".
|
||||||
|
*/
|
||||||
|
export function ContractChangesRequestedView({
|
||||||
|
contract,
|
||||||
|
}: {
|
||||||
|
contract: Freight.IContract;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const auth = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const nationality = auth.company?.company?.nationality as
|
||||||
|
| string
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
const settingQuery = useQuery(
|
||||||
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
|
input: { code: documentSettingCode(nationality) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = contract.files ?? [];
|
||||||
|
const onFile = useMemo(() => dedupeLatestByCode(files), [files]);
|
||||||
|
const existingCodes = useMemo(() => new Set(files.map((f) => f.code)), [files]);
|
||||||
|
|
||||||
|
const [documents, setDocuments] = useState<DocumentsValue>({});
|
||||||
|
const [showErrors, setShowErrors] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const fields = settingQuery.data?.fields ?? [];
|
||||||
|
const missingRequiredKeys = useMemo(
|
||||||
|
() =>
|
||||||
|
fields
|
||||||
|
.filter((f) => f.isRequired)
|
||||||
|
.filter(
|
||||||
|
(f) =>
|
||||||
|
!existingCodes.has(f.fileKey) && !hasFile(documents[f.fileKey]),
|
||||||
|
)
|
||||||
|
.map((f) => f.fileKey),
|
||||||
|
[fields, existingCodes, documents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (docs: DocumentsValue) =>
|
||||||
|
api.contracts.update.call({ id: contract.id, dto: {}, documents: docs }),
|
||||||
|
});
|
||||||
|
const submitMutation = useMutation({
|
||||||
|
mutationFn: () => api.contracts.submit.call({ id: contract.id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.contracts.get.queryKey({ id: contract.id }),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
||||||
|
navigate(`/contracts/${contract.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBusy =
|
||||||
|
settingQuery.isLoading ||
|
||||||
|
updateMutation.isPending ||
|
||||||
|
submitMutation.isPending;
|
||||||
|
|
||||||
|
const resubmit = () => {
|
||||||
|
if (missingRequiredKeys.length > 0) {
|
||||||
|
setShowErrors(true);
|
||||||
|
setError("Please attach all required documents before resubmitting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowErrors(false);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
const docs: DocumentsValue = {};
|
||||||
|
for (const [k, v] of Object.entries(documents)) if (hasFile(v)) docs[k] = v;
|
||||||
|
|
||||||
|
if (Object.keys(docs).length > 0) {
|
||||||
|
updateMutation.mutate(docs, { onSuccess: () => submitMutation.mutate() });
|
||||||
|
} else {
|
||||||
|
submitMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldErrors = showErrors
|
||||||
|
? Object.fromEntries(missingRequiredKeys.map((k) => [k, "Required"]))
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ padding: "28px 32px 40px" }}>
|
||||||
|
<Stack gap="lg" className="mx-auto" style={{ maxWidth: 760 }}>
|
||||||
|
<Group gap="md" align="center" wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
px={8}
|
||||||
|
onClick={() => navigate("/contracts")}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<Text fw={800} fz={22} style={{ color: INK }}>
|
||||||
|
{contract.reference}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Changes requested — update your documents and resubmit.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
radius="lg"
|
||||||
|
variant="light"
|
||||||
|
icon={<AlertCircle size={18} />}
|
||||||
|
title="A reviewer asked for changes"
|
||||||
|
>
|
||||||
|
Update the documents below — replace anything that needs to change and
|
||||||
|
attach any required document that isn't on file yet — then resubmit the
|
||||||
|
contract for review.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||||
|
{onFile.length > 0 && (
|
||||||
|
<Stack gap={8} mb="lg">
|
||||||
|
<Text fz={13} fw={700} style={{ color: INK }}>
|
||||||
|
Already on file
|
||||||
|
</Text>
|
||||||
|
{onFile.map((file) => (
|
||||||
|
<Group
|
||||||
|
key={file.id}
|
||||||
|
gap={12}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
className="rounded-xl"
|
||||||
|
style={{ border: `1px solid ${BORDER}`, padding: "10px 14px" }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: "#EAF1FB",
|
||||||
|
color: "#2E5B96",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={17} />
|
||||||
|
</Box>
|
||||||
|
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Text fz="13px" fw={600} style={{ color: INK }} truncate>
|
||||||
|
{labelForDocCode(file.code)}
|
||||||
|
</Text>
|
||||||
|
<Text fz="12px" c="dimmed" truncate>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Group gap={5} c={GREEN}>
|
||||||
|
<CheckCircle2 size={14} />
|
||||||
|
<Text fz="11.5px" fw={600} c={GREEN}>
|
||||||
|
On file
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={fileViewUrl(file.id, true)}
|
||||||
|
variant="default"
|
||||||
|
size="compact-xs"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Download size={13} />}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text fz={13} fw={700} style={{ color: INK }} mb="xs">
|
||||||
|
Update documents
|
||||||
|
</Text>
|
||||||
|
<Text fz="12px" c="dimmed" mb="sm">
|
||||||
|
Replace any document you need to change. Documents marked required
|
||||||
|
must be on file before you can resubmit.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{settingQuery.isLoading ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : settingQuery.data ? (
|
||||||
|
<SmartFileInput
|
||||||
|
file={settingQuery.data}
|
||||||
|
value={documents}
|
||||||
|
onChange={setDocuments}
|
||||||
|
errors={fieldErrors}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text fz="13px" c="dimmed">
|
||||||
|
No document requirements are configured for your account. You can
|
||||||
|
resubmit using the documents already on file.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
mt="md"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{(updateMutation.isError || submitMutation.isError) && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
mt="md"
|
||||||
|
>
|
||||||
|
Couldn't resubmit. Please try again.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="lg"
|
||||||
|
radius={10}
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Send size={16} />}
|
||||||
|
onClick={resubmit}
|
||||||
|
loading={isBusy}
|
||||||
|
disabled={isBusy}
|
||||||
|
styles={{ root: { height: 46 }, label: { fontSize: 14, fontWeight: 800 } }}
|
||||||
|
>
|
||||||
|
{isBusy ? "Resubmitting…" : "Resubmit for review"}
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContractChangesRequestedView;
|
||||||
@@ -46,6 +46,7 @@ import { fileViewUrl } from "@/constants/apiConfig";
|
|||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||||
|
import { ContractChangesRequestedView } from "./ContractChangesRequestedView";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
import {
|
import {
|
||||||
BORDER,
|
BORDER,
|
||||||
@@ -193,6 +194,12 @@ export default function ContractDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Staff returned the contract for changes — show the edit-and-resubmit view
|
||||||
|
// (update documents → resubmit) instead of the read-only detail.
|
||||||
|
if (contract.status === "CHANGES_REQUESTED") {
|
||||||
|
return <ContractChangesRequestedView contract={contract} />;
|
||||||
|
}
|
||||||
|
|
||||||
const isContainer = contract.freightType === "CONTAINER";
|
const isContainer = contract.freightType === "CONTAINER";
|
||||||
const isGeneral = contract.contractKind === "GENERAL";
|
const isGeneral = contract.contractKind === "GENERAL";
|
||||||
const routes = contract.routes ?? [];
|
const routes = contract.routes ?? [];
|
||||||
|
|||||||
@@ -411,7 +411,10 @@ export interface IContract extends BaseEntity {
|
|||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
serviceName: string;
|
serviceName: string;
|
||||||
|
description?: string | null;
|
||||||
canBeBookedAlone: boolean;
|
canBeBookedAlone: boolean;
|
||||||
|
includesFirstMile?: boolean;
|
||||||
|
includesLastMile?: boolean;
|
||||||
includesCustoms: boolean;
|
includesCustoms: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
paymentCurrency: string;
|
paymentCurrency: string;
|
||||||
@@ -614,6 +617,39 @@ export interface IBookingRequest extends BaseEntity {
|
|||||||
reviewedByStaffId?: string | null;
|
reviewedByStaffId?: string | null;
|
||||||
reviewedAt?: string | null;
|
reviewedAt?: string | null;
|
||||||
reviewNote?: string | null;
|
reviewNote?: string | null;
|
||||||
|
/** Loaded contract relation (request detail response includes it). */
|
||||||
|
contract?: BookingRequestContract | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer (company) summary carried on a request's contract. */
|
||||||
|
export interface BookingRequestCompany {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
tin?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
contactPersonName?: string | null;
|
||||||
|
contactPersonPhone?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slice of the contract surfaced on the shipment-request detail page:
|
||||||
|
* identity, service type (mile/customs flags), customer, routes and cargo scope.
|
||||||
|
*/
|
||||||
|
export interface BookingRequestContract {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
contractKind: ContractKind;
|
||||||
|
tradeDirection: ContractTradeDirection;
|
||||||
|
freightType: ContractFreightType;
|
||||||
|
customsClearingEnabled: boolean;
|
||||||
|
paymentCurrency: string;
|
||||||
|
contractValidUntil?: string | null;
|
||||||
|
company?: BookingRequestCompany | null;
|
||||||
|
serviceType?: IContract["serviceType"];
|
||||||
|
routes?: IContractRoute[];
|
||||||
|
cargoScope?: IContractCargoScope[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBookingRequestDto {
|
export interface CreateBookingRequestDto {
|
||||||
|
|||||||
Reference in New Issue
Block a user