fix issue

This commit is contained in:
Marshal
2026-08-17 07:55:52 +00:00
parent 52fb705a3e
commit c3c3d08a41
7 changed files with 238 additions and 60 deletions

View File

@@ -110,6 +110,7 @@ function makeService(overrides?: {
.fn() .fn()
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents } as never, // transit agents
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
); );
return { return {

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { import {
ContractDocPhase, ContractDocPhase,
isDeliveryOrderFileCode, isDeliveryOrderFileCode,
@@ -29,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service'; import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service'; import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { ContractsRepository } from './contracts.repository';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
@@ -155,6 +157,7 @@ export class BookingClearanceService {
private readonly notifier: BookingLifecycleNotifierService, private readonly notifier: BookingLifecycleNotifierService,
private readonly glExchangeService: GlExchangeService, private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService, private readonly transitAgentsService: TransitAgentsService,
private readonly contractsRepository: ContractsRepository,
) {} ) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> { private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -988,7 +991,39 @@ export class BookingClearanceService {
const milestones = await this.workflowService.listMilestonesForBooking(b.id); const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
} }
return filtered; return this.attachContractSummary(filtered);
}
/**
* Queue rows show the parent contract's reference and lane. Booking has no
* contract relation, and a bare initiated instance may not carry yards yet —
* so batch-load the contracts (with routes) and fill in what's missing:
* `contractReference` always, origin/destination yards only when the booking
* lacks them (its own route wins).
*/
private async attachContractSummary(bookings: Booking[]): Promise<Booking[]> {
const ids = [...new Set(bookings.map((b) => b.contractId).filter(Boolean))] as string[];
if (!ids.length) return bookings;
const contracts = await this.contractsRepository.findAll({
where: { id: In(ids) },
relations: { routes: { originYard: true, destinationYard: true } },
});
const byId = new Map(contracts.map((c) => [c.id, c]));
for (const b of bookings) {
const contract = b.contractId ? byId.get(b.contractId) : undefined;
if (!contract) continue;
const row = b as Booking & { contractReference?: string | null };
row.contractReference = contract.reference ?? null;
if (b.originYard && b.destinationYard) continue;
const routes = contract.routes ?? [];
const route =
routes.find((r) => r.id === b.contractRouteId) ??
(routes.length === 1 ? routes[0] : undefined);
if (!route) continue;
b.originYard = b.originYard ?? route.originYard;
b.destinationYard = b.destinationYard ?? route.destinationYard;
}
return bookings;
} }
async djQueue(): Promise<Booking[]> { async djQueue(): Promise<Booking[]> {
@@ -1008,6 +1043,6 @@ export class BookingClearanceService {
filtered.push(b); filtered.push(b);
} }
} }
return filtered; return this.attachContractSummary(filtered);
} }
} }

View File

@@ -52,6 +52,47 @@ import {
summarizeRequestedCargo, summarizeRequestedCargo,
} from "@/features/clearance/requestedCargo"; } from "@/features/clearance/requestedCargo";
import { contractsService } from "@/services/contracts.service"; import { contractsService } from "@/services/contracts.service";
import "./contract-clearance-table.css";
/** Yards carry `label` (API) — older shapes used `name`/`code`. */
function yardLabel(
yard?: { label?: string; code?: string; name?: string } | null,
): string {
if (!yard) return "—";
return yard.label ?? yard.name ?? yard.code ?? "—";
}
/**
* "Origin → Destination", wrapping past 120px as "Addis Ababa" /
* "→ Djibouti": the arrow is glued to the destination with an nbsp, and
* text wraps normally (the table's cells are otherwise nowrap) so a long
* lane never spills into the next column.
*/
function RouteLabel({
origin,
destination,
}: {
origin: string;
destination: string;
}) {
return (
<Text
size="sm"
maw={120}
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
);
}
function CustomsBadge({ customs }: { customs: boolean }) { function CustomsBadge({ customs }: { customs: boolean }) {
return customs ? ( return customs ? (
@@ -118,8 +159,8 @@ export default function ContractClearanceListPage() {
id: b.id, id: b.id,
reference: b.reference, reference: b.reference,
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—", customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
originLabel: b.originYard?.name ?? "—", originLabel: yardLabel(b.originYard),
destinationLabel: b.destinationYard?.name ?? "—", destinationLabel: yardLabel(b.destinationYard),
tradeDirection: b.tradeDirection ?? "—", tradeDirection: b.tradeDirection ?? "—",
freightType: b.freightType ?? "—", freightType: b.freightType ?? "—",
status: b.status, status: b.status,
@@ -430,11 +471,10 @@ function ShipmentBookingsTable({
id: "route", id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>, header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Group gap={6} wrap="nowrap"> <RouteLabel
<Text size="sm">{row.original.originLabel}</Text> origin={row.original.originLabel}
<ArrowRight size={13} className="shrink-0 text-muted-foreground" /> destination={row.original.destinationLabel}
<Text size="sm">{row.original.destinationLabel}</Text> />
</Group>
), ),
}, },
{ {
@@ -600,13 +640,13 @@ function ShipmentBookingsTable({
} }
return ( return (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs"> <Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<DataTable<ShipmentBookingRow, unknown> <DataTable<ShipmentBookingRow, unknown>
columns={columns} columns={columns}
data={rows} data={rows}
status={loading ? "loading" : error ? "error" : "success"} status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)} onRowClick={(row) => onOpen(row.id)}
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words" containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
/> />
</Box> </Box>
); );

View File

@@ -45,6 +45,7 @@ import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings"; import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import "./contract-clearance-table.css";
const prettyStatus = (s?: string | null) => const prettyStatus = (s?: string | null) =>
(s ?? "") (s ?? "")
@@ -214,15 +215,24 @@ function RouteCell({
}) { }) {
return ( return (
<Stack gap={4} py={2}> <Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap"> {/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
<Text size="sm" fw={500}> normally (cells are otherwise nowrap) so it never spills over. */}
{origin} <Text
</Text> size="sm"
<ArrowRight size={14} className="shrink-0 text-muted-foreground" /> fw={500}
<Text size="sm" fw={500}> maw={120}
{destination} lh={1.35}
</Text> style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
</Group> >
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
<Group gap={8} align="center"> <Group gap={8} align="center">
<DirectionIcon direction={direction} /> <DirectionIcon direction={direction} />
<Badge size="xs" variant="default" radius="sm"> <Badge size="xs" variant="default" radius="sm">
@@ -676,7 +686,7 @@ export default function GlDjiboutiClearanceListPage() {
) : null} ) : null}
</Stack> </Stack>
) : ( ) : (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs"> <Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<DataTable<ShipmentRow, unknown> <DataTable<ShipmentRow, unknown>
columns={shipmentColumns} columns={shipmentColumns}
data={pagedShipmentRows} data={pagedShipmentRows}
@@ -694,7 +704,7 @@ export default function GlDjiboutiClearanceListPage() {
manualPagination: true, manualPagination: true,
pageCount, pageCount,
}} }}
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words" containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
footer={DataTableFooter} footer={DataTableFooter}
/> />
</Box> </Box>

View File

@@ -0,0 +1,94 @@
/*
* Scoped to .edr-clearance-table — the DataTable container div on the
* Document Clearance hubs (GL Ethiopia + GL Djibouti). Mirrors the portal's /bookings table
* (bookings-table.css): content-sized columns with a 100px floor, no
* truncation, horizontal scroll when the table outgrows the card, sticky
* header row and a sticky shadowed action column.
*/
.edr-clearance-table {
overflow-x: auto;
max-width: 100%;
min-width: 0;
}
/*
* width: max-content — the table is exactly as wide as its columns' content
* needs, never squeezed to fit the viewport; the container scrolls instead.
* min-width: 100% keeps it filling the card when content is narrow.
*/
.edr-clearance-table table {
table-layout: auto;
width: max-content;
min-width: 100%;
}
/* 100px floor, no ceiling: cells grow to fit their text, nothing is clipped. */
.edr-clearance-table th,
.edr-clearance-table td:not([colspan]) {
min-width: 100px;
max-width: none;
overflow: visible;
text-overflow: clip;
white-space: nowrap;
}
/*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label. Let badges size
* to their text so the column grows to fit them.
*/
.edr-clearance-table .mantine-Badge-root {
max-width: none;
}
/*
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
* In an auto-width table cell that resolves against min-content and collapses
* the badges/text in the Type, Route and Status columns to nothing. Let group
* children size to their content; the column grows and the container scrolls.
*/
.edr-clearance-table .mantine-Group-root > * {
max-width: none;
flex-shrink: 0;
}
/* Sticky header row. */
.edr-clearance-table thead th {
position: sticky;
top: 0;
z-index: 1;
}
/*
* Sticky action column, shrunk to its content. The width overrides the inline
* width DataTable stamps from tanstack's column size — hence !important.
* `:not([colspan])` keeps the full-width error/empty rows out.
*/
.edr-clearance-table th:last-child,
.edr-clearance-table td:last-child:not([colspan]) {
width: 1% !important;
min-width: 0;
position: sticky;
right: 0;
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
}
/*
* Sticky cells sit above the scrolling ones, so they need their own opaque
* background or the columns underneath show through.
*/
.edr-clearance-table td:last-child:not([colspan]) {
background: #f5f8fb;
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) {
background: var(--accent, #f4fbf8);
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-clearance-table th:last-child {
background: #f4f7fa;
z-index: 3;
}

View File

@@ -5,14 +5,20 @@ import {
Card, Card,
Group, Group,
SegmentedControl, SegmentedControl,
SimpleGrid,
Stack, Stack,
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { RefreshCw, Search, X } from "lucide-react"; import {
Banknote,
CircleDollarSign,
Landmark,
RefreshCw,
Search,
X,
} from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -22,6 +28,7 @@ import {
formatMoney, formatMoney,
humanize, humanize,
} from "@/components/customers"; } from "@/components/customers";
import { KpiStrip } from "@/components/page";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions"; import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings"; import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
import { api } from "@/services/api"; import { api } from "@/services/api";
@@ -83,7 +90,7 @@ export default function InvoicesPanel() {
// Summary card: total collected (paidAmount) across every invoice matching // Summary card: total collected (paidAmount) across every invoice matching
// the current search/status filters, not just the visible page. // the current search/status filters, not just the visible page.
const { data: summary } = useQuery( const { data: summary, isLoading: summaryLoading } = useQuery(
api.invoices.collectedSummary.queryOptions({ api.invoices.collectedSummary.queryOptions({
input: { input: {
filter: { search: debouncedQuery, status: statusFilter || undefined }, filter: { search: debouncedQuery, status: statusFilter || undefined },
@@ -190,39 +197,30 @@ export default function InvoicesPanel() {
return ( return (
<Stack gap="md"> <Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 3 }}> <KpiStrip
<Card withBorder radius="md" padding="md"> loading={summaryLoading}
<Text size="xs" c="dimmed" fw={600} tt="uppercase"> items={[
Total collected {
</Text> label: "Total collected",
<Text size="xl" fw={700} c="edr-text"> hint: etbFromUsd !== null ? "ETB + USD" : "ETB only",
{etbFromUsd !== null value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"),
? formatMoney(etbCollected + etbFromUsd, "ETB") icon: CircleDollarSign,
: formatMoney(etbCollected, "ETB")} color: "edr-green",
</Text> },
<Text size="xs" c="dimmed"> {
{etbFromUsd !== null label: "Collected in ETB",
? `Includes ${formatMoney(usdCollected, "USD")} converted @ ${rate} ETB/USD` value: formatMoney(etbCollected, "ETB"),
: "USD rate unavailable — ETB collected only"} icon: Banknote,
</Text> color: "blue",
</Card> },
<Card withBorder radius="md" padding="md"> {
<Text size="xs" c="dimmed" fw={600} tt="uppercase"> label: "Collected in USD",
Collected ETB only value: formatMoney(usdCollected, "USD"),
</Text> icon: Landmark,
<Text size="xl" fw={700} c="edr-text"> color: "violet",
{formatMoney(etbCollected, "ETB")} },
</Text> ]}
</Card> />
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
Collected USD only
</Text>
<Text size="xl" fw={700} c="edr-text">
{formatMoney(usdCollected, "USD")}
</Text>
</Card>
</SimpleGrid>
<Card p={0}> <Card p={0}>
<Stack gap={0}> <Stack gap={0}>

View File

@@ -235,7 +235,7 @@ export function WagonCancellationCard({
<SectionCard> <SectionCard>
<Group justify="space-between" align="center" mb="sm"> <Group justify="space-between" align="center" mb="sm">
<CardTitle>Wagon Cancellation</CardTitle> <CardTitle>Wagon Cancellation</CardTitle>
{canRequest && !openRow && !creditRow && ( {/* {canRequest && !openRow && !creditRow && (
<Button <Button
variant="default" variant="default"
radius="md" radius="md"
@@ -244,7 +244,7 @@ export function WagonCancellationCard({
> >
Cancel wagons Cancel wagons
</Button> </Button>
)} )} */}
</Group> </Group>
{openRow ? ( {openRow ? (