mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix issue
This commit is contained in:
@@ -110,6 +110,7 @@ function makeService(overrides?: {
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
|
||||
} as never, // transit agents
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
isDeliveryOrderFileCode,
|
||||
@@ -29,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { GlExchangeService } from './gl-exchange.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
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';
|
||||
|
||||
@@ -155,6 +157,7 @@ export class BookingClearanceService {
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly glExchangeService: GlExchangeService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
) {}
|
||||
|
||||
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
||||
@@ -988,7 +991,39 @@ export class BookingClearanceService {
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
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[]> {
|
||||
@@ -1008,6 +1043,6 @@ export class BookingClearanceService {
|
||||
filtered.push(b);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
return this.attachContractSummary(filtered);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,47 @@ import {
|
||||
summarizeRequestedCargo,
|
||||
} from "@/features/clearance/requestedCargo";
|
||||
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 }) {
|
||||
return customs ? (
|
||||
@@ -118,8 +159,8 @@ export default function ContractClearanceListPage() {
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
|
||||
originLabel: b.originYard?.name ?? "—",
|
||||
destinationLabel: b.destinationYard?.name ?? "—",
|
||||
originLabel: yardLabel(b.originYard),
|
||||
destinationLabel: yardLabel(b.destinationYard),
|
||||
tradeDirection: b.tradeDirection ?? "—",
|
||||
freightType: b.freightType ?? "—",
|
||||
status: b.status,
|
||||
@@ -430,11 +471,10 @@ function ShipmentBookingsTable({
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm">{row.original.originLabel}</Text>
|
||||
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm">{row.original.destinationLabel}</Text>
|
||||
</Group>
|
||||
<RouteLabel
|
||||
origin={row.original.originLabel}
|
||||
destination={row.original.destinationLabel}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -600,13 +640,13 @@ function ShipmentBookingsTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
|
||||
<DataTable<ShipmentBookingRow, unknown>
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={loading ? "loading" : error ? "error" : "success"}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -45,6 +45,7 @@ import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import "./contract-clearance-table.css";
|
||||
|
||||
const prettyStatus = (s?: string | null) =>
|
||||
(s ?? "")
|
||||
@@ -214,15 +215,24 @@ function RouteCell({
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={500}>
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500}>
|
||||
{/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
|
||||
normally (cells are otherwise nowrap) so it never spills over. */}
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
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>
|
||||
</Group>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={direction} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
@@ -676,7 +686,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
|
||||
<DataTable<ShipmentRow, unknown>
|
||||
columns={shipmentColumns}
|
||||
data={pagedShipmentRows}
|
||||
@@ -694,7 +704,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
manualPagination: true,
|
||||
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}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -5,14 +5,20 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
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 { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -22,6 +28,7 @@ import {
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
||||
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
|
||||
import { api } from "@/services/api";
|
||||
@@ -83,7 +90,7 @@ export default function InvoicesPanel() {
|
||||
|
||||
// Summary card: total collected (paidAmount) across every invoice matching
|
||||
// the current search/status filters, not just the visible page.
|
||||
const { data: summary } = useQuery(
|
||||
const { data: summary, isLoading: summaryLoading } = useQuery(
|
||||
api.invoices.collectedSummary.queryOptions({
|
||||
input: {
|
||||
filter: { search: debouncedQuery, status: statusFilter || undefined },
|
||||
@@ -190,39 +197,30 @@ export default function InvoicesPanel() {
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Total collected
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c="edr-text">
|
||||
{etbFromUsd !== null
|
||||
? formatMoney(etbCollected + etbFromUsd, "ETB")
|
||||
: formatMoney(etbCollected, "ETB")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{etbFromUsd !== null
|
||||
? `Includes ${formatMoney(usdCollected, "USD")} converted @ ${rate} ETB/USD`
|
||||
: "USD rate unavailable — ETB collected only"}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Collected — ETB only
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c="edr-text">
|
||||
{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>
|
||||
<KpiStrip
|
||||
loading={summaryLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Total collected",
|
||||
hint: etbFromUsd !== null ? "ETB + USD" : "ETB only",
|
||||
value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"),
|
||||
icon: CircleDollarSign,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Collected in ETB",
|
||||
value: formatMoney(etbCollected, "ETB"),
|
||||
icon: Banknote,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
label: "Collected in USD",
|
||||
value: formatMoney(usdCollected, "USD"),
|
||||
icon: Landmark,
|
||||
color: "violet",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
|
||||
@@ -235,7 +235,7 @@ export function WagonCancellationCard({
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<CardTitle>Wagon Cancellation</CardTitle>
|
||||
{canRequest && !openRow && !creditRow && (
|
||||
{/* {canRequest && !openRow && !creditRow && (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
@@ -244,7 +244,7 @@ export function WagonCancellationCard({
|
||||
>
|
||||
Cancel wagons
|
||||
</Button>
|
||||
)}
|
||||
)} */}
|
||||
</Group>
|
||||
|
||||
{openRow ? (
|
||||
|
||||
Reference in New Issue
Block a user