fix issue

This commit is contained in:
Marshal
2026-07-16 12:59:28 +00:00
parent e8e12c1363
commit b50adc4666
6 changed files with 160 additions and 12 deletions

View File

@@ -204,17 +204,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
if (contracts.length === 0) return;
const ids = contracts.map((c) => c.id);
const rows: Array<{ contract_id: string; current_phase: string | null }> =
await this.dataSource.query(
`SELECT DISTINCT ON (contract_id) contract_id, current_phase
FROM freight.contract_clearance_cycles
WHERE contract_id = ANY($1)
ORDER BY contract_id, cycle_number DESC`,
[ids],
);
const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase]));
const rows: Array<{
contract_id: string;
current_phase: string | null;
booking_id: string | null;
booking_status: string | null;
}> = await this.dataSource.query(
`SELECT DISTINCT ON (ccc.contract_id)
ccc.contract_id, ccc.current_phase,
b.id AS booking_id, b.status AS booking_status
FROM freight.contract_clearance_cycles ccc
LEFT JOIN freight.bookings b ON b.id = ccc.booking_id
WHERE ccc.contract_id = ANY($1)
ORDER BY ccc.contract_id, ccc.cycle_number DESC`,
[ids],
);
const byContract = new Map(rows.map((r) => [r.contract_id, r]));
for (const contract of contracts) {
contract.clearancePhase = byContract.get(contract.id) ?? null;
const row = byContract.get(contract.id);
contract.clearancePhase = row?.current_phase ?? null;
contract.latestCycleBookingId = row?.booking_id ?? null;
contract.latestCycleBookingStatus = row?.booking_status ?? null;
}
}

View File

@@ -312,6 +312,14 @@ export class Contract extends BaseEntity {
*/
clearancePhase?: string | null;
/**
* Latest clearance cycle's linked booking (id + status), attached alongside
* clearancePhase. Lets the GL queue tell an expired (unpaid) booking apart
* from a live one so it can offer a rebook. Not columns.
*/
latestCycleBookingId?: string | null;
latestCycleBookingStatus?: string | null;
/**
* Body of the most recent CHANGES_REQUESTED review note, attached by
* ContractsService.findById so the portal can show the customer what staff

View File

@@ -13,3 +13,6 @@ export function fileViewUrl(fileId: string, download = false): string {
const base = `${API_BASE_URL}/api/files/${fileId}`;
return download ? `${base}?download=1` : base;
}

View File

@@ -5,6 +5,7 @@ import {
Alert,
Badge,
Box,
Button,
Grid,
Group,
Loader,
@@ -21,10 +22,18 @@ import {
CheckCircle2,
Clock,
PackageCheck,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { Link } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import {
FREIGHT_PERMS,
hasPermission,
isDjiboutiGl,
} from "@/lib/permissions";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
@@ -44,6 +53,7 @@ import {
export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
const { data: contract, refetch: refetchContract } = useContractDetail(id);
const {
@@ -106,6 +116,16 @@ export default function ContractClearanceDetailPage() {
const reviewReadOnly = shipmentLocked;
const queriesLocked = Boolean(clearance?.preClearanceFinalized);
const bookingHref = `/dashboard/contracts/${id}/create-booking`;
// The GL-created booking expired unpaid — the slot is free again and GL
// rebooks on the customer's behalf (customs bookings are never self-booked).
const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED";
const canRebook =
bookingExpired &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const rebookHref = linkedBookingId
? `${bookingHref}?copyFrom=${linkedBookingId}`
: bookingHref;
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
useBookingMilestones(linkedBookingId);
@@ -158,7 +178,16 @@ export default function ContractClearanceDetailPage() {
{ label: reference },
]}
meta={
bookingAlreadyCreated ? (
bookingExpired ? (
<Badge
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={13} />}
>
Payment expired rebook
</Badge>
) : bookingAlreadyCreated ? (
<Badge
variant="light"
color="blue"
@@ -204,7 +233,49 @@ export default function ContractClearanceDetailPage() {
it can actually create the booking without checking the schedule board. */}
{id ? <GlUpcomingWindowsSection contractId={id} /> : null}
{bookingAlreadyCreated ? (
{bookingExpired ? (
<Alert
color="orange"
radius="md"
icon={<RefreshCw size={16} />}
title="Booking expired — payment not received"
>
<Stack gap="sm" align="flex-start">
<Text size="sm">
The customer did not pay before the deadline, so the booking
expired and its train slot was released. The contract slot is
free again GL Ethiopia can rebook on the customer&apos;s
behalf without re-running clearance.
{linkedBookingId ? (
<>
{" "}
<Text
component={Link}
to={`/dashboard/bookings/${linkedBookingId}/clearance`}
inherit
fw={600}
c="orange.8"
>
View expired booking
</Text>
</>
) : null}
</Text>
{canRebook ? (
<Button
component={Link}
to={rebookHref}
color="grape"
radius="md"
size="sm"
leftSection={<RefreshCw size={15} />}
>
Rebook for customer
</Button>
) : null}
</Stack>
</Alert>
) : bookingAlreadyCreated ? (
<Alert
color="blue"
radius="md"

View File

@@ -92,6 +92,10 @@ interface ClearanceRow {
ready: boolean;
/** true once GL Ethiopia created the shipment booking. */
bookingCreated: boolean;
/** true when the created booking EXPIRED unpaid — GL must rebook. */
paymentExpired: boolean;
/** The expired booking, so rebook can copy its cargo. */
expiredBookingId: string | null;
}
function yardLabel(
@@ -145,6 +149,13 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
status: contract.status,
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
paymentExpired:
contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS" &&
contract.latestCycleBookingStatus === "EXPIRED",
expiredBookingId:
contract.latestCycleBookingStatus === "EXPIRED"
? (contract.latestCycleBookingId ?? null)
: null,
};
}
@@ -186,6 +197,24 @@ function DirectionIcon({ direction }: { direction: string }) {
}
function StatusBadge({ row }: { row: ClearanceRow }) {
if (row.paymentExpired) {
return (
<Tooltip
label="The customer did not pay in time — the booking expired. GL rebooks on the customer's behalf."
withArrow
>
<Badge
size="sm"
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={12} />}
>
Payment expired
</Badge>
</Tooltip>
);
}
if (row.bookingCreated) {
return (
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
@@ -519,6 +548,27 @@ export default function ContractClearanceListPage() {
Create booking
</Button>
</Group>
) : row.original.paymentExpired && canCreateBooking ? (
<Group justify="flex-end" pr="xs">
<Button
size="compact-sm"
color="grape"
radius="md"
leftSection={<RefreshCw size={14} />}
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/${row.original.id}/create-booking${
row.original.expiredBookingId
? `?copyFrom=${row.original.expiredBookingId}`
: ""
}`,
);
}}
>
Rebook
</Button>
</Group>
) : (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />

View File

@@ -620,6 +620,12 @@ export interface IContract extends BaseEntity {
* clearance view per contract.
*/
clearancePhase?: ContractDocPhase | string | null;
/**
* Latest clearance cycle's linked booking id + status (list responses only).
* An EXPIRED status means the customer never paid — GL may rebook.
*/
latestCycleBookingId?: string | null;
latestCycleBookingStatus?: string | null;
pricingBreakdown?: ContractPricingBreakdown | null;
pricingDisplayMode?: "UNIT_RATES";