Merge branch 'freight/nati-2' into freight/feat/element-chat

This commit is contained in:
Nathnael
2026-08-17 12:53:39 +00:00
63 changed files with 2408 additions and 1126 deletions

View File

@@ -324,7 +324,7 @@ const App = () => {
}
/>
{/* Merged Invoices / Payments / USD Payments hub — tabs switch via
?tab=invoices|payments|usd-payments (default invoices). Access is
?tab=invoices|payments|manual-payments (default invoices). Access is
OR'd across both keys so a user with just one still gets in; each
tab hides itself if the user lacks the permission it used to be
routed on. */}
@@ -361,7 +361,7 @@ const App = () => {
/>
<Route
path="usd-payments"
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />}
element={<Navigate to="/dashboard/invoices?tab=manual-payments" replace />}
/>
<Route
path="invoices/:id"

View File

@@ -20,11 +20,10 @@ import {
FileX2,
} from "lucide-react";
import { useState } from "react";
import { useFileViewer } from "@edr/ui-common";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { fetchViewableFile } from "@/services/files.service";
import { openFileInNewTab } from "@/services/files.service";
import { api } from "@/services/api";
import type { Company } from "@/types/customer";
import { formatDate, humanize } from "./format";
@@ -227,7 +226,6 @@ export function ChangeRequestReview({ company }: { company: Company }) {
api.customers.requestChangeRequestChanges.mutationOptions(),
);
const { view, viewer } = useFileViewer();
const [actionTarget, setActionTarget] = useState<{
id: string;
kind: "reject" | "request-changes";
@@ -350,10 +348,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button"
size="sm"
onClick={() =>
void fetchViewableFile(
openFileInNewTab(
c.fileId,
c.fileName ?? humanize(c.code),
).then(view)
)
}
style={{
textDecoration:
@@ -382,12 +380,7 @@ export function ChangeRequestReview({ company }: { company: Company }) {
component="button"
type="button"
size="sm"
onClick={() =>
void fetchViewableFile(
fileId,
`Document ${i + 1}`,
).then(view)
}
onClick={() => openFileInNewTab(fileId, `Document ${i + 1}`)}
>
Document {i + 1}
</Anchor>
@@ -421,10 +414,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button"
size="sm"
onClick={() =>
void fetchViewableFile(
openFileInNewTab(
c.fileId,
c.fileName ?? "License document",
).then(view)
)
}
style={{
textDecoration:
@@ -532,8 +525,6 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</Group>
</Stack>
</Modal>
{viewer}
</>
);
}

View File

@@ -1,9 +1,8 @@
import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { FilePlus2, FileX2, History } from "lucide-react";
import { useFileViewer } from "@edr/ui-common";
import { fetchViewableFile } from "@/services/files.service";
import { openFileInNewTab } from "@/services/files.service";
import { api } from "@/services/api";
import type {
Company,
@@ -36,6 +35,12 @@ interface TimelineEntry {
at: string;
note?: string | null;
summary?: string;
/** Who filed the change (the customer, or staff editing during onboarding). */
requestedBy?: string | null;
/** When they filed it — the "asked" half of the ask/decide pair below. */
requestedAt?: string | null;
/** Who decided (approved / rejected / sent it back to marketing). */
decidedBy?: string | null;
fieldDiffs: FieldDiff[];
docDiffs: DocDiff[];
}
@@ -43,10 +48,18 @@ interface TimelineEntry {
const KIND_BADGE: Record<TimelineEntry["kind"], { label: string; color: string }> = {
approved: { label: "Approved", color: "edr-green" },
rejected: { label: "Rejected", color: "red" },
changes_requested: { label: "Changes requested", color: "yellow" },
// Sending a request back is what "reverted to marketing" means here: the
// request stays open and marketing owns the follow-up with the customer.
changes_requested: { label: "Sent back to marketing", color: "yellow" },
revision: { label: "Recorded", color: "blue" },
};
/** "Requested by X" / "Reviewed by X", with the id-less case reading sanely. */
function actorLine(verb: string, who?: string | null, when?: string | null) {
if (!who && !when) return null;
return `${verb}${who ? ` by ${who}` : ""}${when ? ` · ${formatDate(when)}` : ""}`;
}
/**
* Pair adjacent remove-then-add intents into one before/after doc diff — a
* "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together
@@ -132,6 +145,9 @@ function fromChangeRequest(
kind: r.status as TimelineEntry["kind"],
at: r.reviewedAt ?? r.updatedAt,
note: r.note,
requestedBy: r.submittedByName,
requestedAt: r.submittedAt ?? r.createdAt,
decidedBy: r.reviewedByName,
fieldDiffs,
docDiffs,
};
@@ -156,6 +172,7 @@ function fromRevision(rev: CompanyRevision): TimelineEntry {
kind: "revision",
at: rev.createdAt,
summary: rev.summary,
requestedBy: rev.actorName,
fieldDiffs,
docDiffs,
};
@@ -170,7 +187,6 @@ function fromRevision(rev: CompanyRevision): TimelineEntry {
* single answer instead of two places to check.
*/
export function CompanyTimeline({ company }: { company: Company }) {
const { view, viewer } = useFileViewer();
const changeRequestsQuery = useQuery(
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
);
@@ -186,7 +202,7 @@ export function CompanyTimeline({ company }: { company: Company }) {
].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
const openFile = (file: { id: string; name: string }) =>
void fetchViewableFile(file.id, file.name).then(view);
openFileInNewTab(file.id, file.name);
if (entries.length === 0) {
return (
@@ -205,6 +221,20 @@ export function CompanyTimeline({ company }: { company: Company }) {
<Stack gap="md">
{entries.map((entry) => {
const badge = KIND_BADGE[entry.kind];
const requestedLine = actorLine(
entry.kind === "revision" ? "Edited" : "Requested",
entry.requestedBy,
entry.requestedAt,
);
const decidedLine = actorLine(
entry.kind === "changes_requested"
? "Sent back to marketing"
: entry.kind === "rejected"
? "Rejected"
: "Approved",
entry.decidedBy,
entry.kind === "revision" ? null : entry.at,
);
return (
<Card key={entry.id} withBorder>
<Stack gap="sm">
@@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) {
</Text>
</Group>
{/* Who asked, and who decided. Without this the feed said what
changed and when, but never named a person — the first thing
anyone auditing a returned request needs. */}
{(requestedLine || decidedLine) && (
<Stack gap={2}>
{requestedLine && (
<Text size="xs" c="dimmed">
{requestedLine}
</Text>
)}
{decidedLine && (
<Text size="xs" c="dimmed">
{decidedLine}
</Text>
)}
</Stack>
)}
{entry.note && (
<Alert color="yellow" variant="light">
<Text size="sm">
<strong>Note:</strong> {entry.note}
<strong>
{entry.kind === "changes_requested"
? "What was asked for:"
: "Note:"}
</strong>{" "}
{entry.note}
</Text>
</Alert>
)}
@@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) {
</Card>
);
})}
{viewer}
</Stack>
);
}

View File

@@ -3,34 +3,28 @@ import {
ActionIcon,
Box,
Card,
Group,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, Search, Ship, User, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { FileText, Inbox, RefreshCw, Ship, User } from "lucide-react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { PageContainer, PageHeader } from "@/components/page";
import { bookingsService } from "@/services/bookings.service";
import { bookingsService, type BookingListFilter } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking";
import {
Badge,
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { dateRangeParams, FilterBar, useFilters, type FilterDef } from "@/components/filters";
/**
* Operations "Clearance Documents" hub — the worklist for self-clearance
@@ -43,16 +37,14 @@ import {
const PAGE_SIZE = 10;
/**
* Status filter options (values = `statuses` param). FULLY_EXECUTED is the
* post-approval status of intercity (domestic) bookings kept in the list as
* history, otherwise an approved intercity row vanishes from the hub.
* The hub's baseline scope — FULLY_EXECUTED is the post-approval status of
* intercity (domestic) bookings, kept in as history so an approved intercity
* row doesn't just vanish. Sent whenever the Status pill has no narrower pick.
*/
const BOOKING_STATUS_OPTIONS = [
{
value:
"AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED",
label: "All statuses",
},
const DEFAULT_STATUSES =
"AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED";
const STATUS_OPTIONS = [
{ value: "AWAITING_DOCUMENTS", label: "Awaiting documents" },
{ value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" },
{ value: "CLEARANCE_READY", label: "Clearance ready" },
@@ -81,77 +73,59 @@ const CUSTOMER_KIND_OPTIONS = [
{ value: "CUSTOMER", label: "Customer" },
];
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [bookingStatuses, setBookingStatuses] = useState(
BOOKING_STATUS_OPTIONS[0].value,
);
const { filterOptions } = useMyTradeAccess();
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [customerKindFilter, setCustomerKindFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
const search = debouncedQuery.trim() || undefined;
const filterDefs: FilterDef[] = useMemo(
() => [
{
key: "status",
label: "Status",
type: "enum",
multiple: false,
options: STATUS_OPTIONS,
// No pick ⇒ no `statuses` param at all; the query fills in
// DEFAULT_STATUSES itself, same as the old Select's "All statuses" row.
toParams: ({ v }) => ({ statuses: v[0] }),
},
{
key: "tradeDirection",
label: "Direction",
type: "enum",
multiple: false,
options: filterOptions(TRADE_DIRECTION_OPTIONS),
},
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS },
{
key: "created",
label: "Created",
type: "date",
toParams: dateRangeParams("createdFrom", "createdTo"),
},
],
[filterOptions],
);
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: PAGE_SIZE });
}, [setPagination]);
const controls = useFilters(filterDefs, { pageSize: PAGE_SIZE });
const page = pagination.pageIndex + 1;
const filter: BookingListFilter = useMemo(
() => ({
...(controls.params as unknown as BookingListFilter),
// Self-clearance instances carry bookingType=ONE_TIME whatever their
// contract kind, so customsClearingEnabled=false + the status scope
// above are what isolate exactly this worklist.
customsClearingEnabled: "false",
statuses: (controls.params.statuses as string | undefined) ?? DEFAULT_STATUSES,
}),
[controls.params],
);
const bookingsQuery = useQuery({
queryKey: [
"clearance-documents",
"bookings",
bookingStatuses,
directionFilter,
freightTypeFilter,
ownershipFilter,
customerKindFilter,
createdFrom,
createdTo,
page,
search,
],
queryFn: () =>
// Self-clearance instances carry bookingType=ONE_TIME whatever their
// contract kind, so customsClearingEnabled=false + the three per-booking
// clearance statuses are what isolate exactly this worklist.
bookingsService.list({
statuses: bookingStatuses,
customsClearingEnabled: "false",
page,
pageSize: PAGE_SIZE,
search,
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(customerKindFilter
? { customerKind: customerKindFilter as "SHIPPING_LINE" | "CUSTOMER" }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
}),
queryKey: ["clearance-documents", "bookings", filter],
queryFn: () => bookingsService.list(filter),
placeholderData: keepPreviousData,
});
@@ -256,7 +230,6 @@ export default function ClearanceDocumentsPage() {
);
const total = bookingsQuery.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const showEmpty =
!bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0;
const tableStatus = bookingsQuery.isLoading
@@ -288,116 +261,12 @@ export default function ClearanceDocumentsPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search booking, contract, customer or shipping line…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
data={BOOKING_STATUS_OPTIONS}
value={bookingStatuses}
onChange={(v) => {
setBookingStatuses(v ?? BOOKING_STATUS_OPTIONS[0].value);
resetPage();
}}
allowDeselect={false}
radius="lg"
w={220}
aria-label="Filter by status"
/>
</Group>
<Group gap="sm" mt="sm" wrap="wrap">
<Select
placeholder="Direction"
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 130 }}
aria-label="Filter by direction"
/>
<Select
placeholder="Freight type"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Booked by"
data={CUSTOMER_KIND_OPTIONS}
value={customerKindFilter}
onChange={(v) => {
setCustomerKindFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by booked by"
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
value={ownershipFilter}
onChange={(v) => {
setOwnershipFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by ownership"
/>
<DatePickerInput
type="range"
placeholder="Created date range"
value={[createdFrom, createdTo]}
onChange={([from, to]) => {
setCreatedFrom(from ? new Date(from) : null);
setCreatedTo(to ? new Date(to) : null);
resetPage();
}}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 220 }}
aria-label="Created date range"
/>
</Group>
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="clearance-documents"
/>
</Box>
{showEmpty ? (
@@ -420,18 +289,7 @@ export default function ClearanceDocumentsPage() {
state: { from: "/dashboard/contracts/clearance-documents" },
})
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
footer={DataTableFooter}
/>

View File

@@ -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>
);

View File

@@ -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}>
{destination}
</Text>
</Group>
{/* 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 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>

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

@@ -23,6 +23,7 @@ import {
Banknote,
Contact,
Download,
ExternalLink,
Eye,
FileSignature,
FileText,
@@ -69,7 +70,7 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
downloadBookingFile,
fetchViewableFile,
openFileInNewTab,
} from "@/services/files.service";
import { api } from "@/services/api";
import type {
@@ -81,12 +82,7 @@ import type {
} from "@/types/customer";
import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer";
import type { Invoice } from "@/types/invoice";
import {
DataTable,
useFileViewer,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
@@ -146,7 +142,6 @@ const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
const { data: company, isLoading } = useQuery(
@@ -271,9 +266,7 @@ export default function CustomerDetailPage() {
variant="subtle"
color="gray"
aria-label={`View ${f.name}`}
onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
onClick={() => openFileInNewTab(f.id, f.name)}
>
<Eye size={14} />
</ActionIcon>
@@ -282,9 +275,7 @@ export default function CustomerDetailPage() {
type="button"
size="xs"
lineClamp={1}
onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
onClick={() => openFileInNewTab(f.id, f.name)}
style={{
maxWidth: 170,
textAlign: "left",
@@ -339,7 +330,7 @@ export default function CustomerDetailPage() {
),
},
],
[view, canReview],
[canReview],
);
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
@@ -522,9 +513,7 @@ export default function CustomerDetailPage() {
aria-label="View"
data-stop-row-click
onClick={() =>
void fetchViewableFile(row.original.id, row.original.name).then(
view,
)
openFileInNewTab(row.original.id, row.original.name)
}
>
<Eye size={16} />
@@ -564,7 +553,7 @@ export default function CustomerDetailPage() {
),
},
],
[view, canRequestDocChange],
[canRequestDocChange],
);
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
@@ -1163,9 +1152,7 @@ export default function CustomerDetailPage() {
lineClamp={1}
style={{ flex: 1, textAlign: "left" }}
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then(
view,
)
openFileInNewTab(doc.id, doc.name)
}
>
{doc.name}
@@ -1176,9 +1163,7 @@ export default function CustomerDetailPage() {
color="gray"
aria-label={`Preview ${doc.name}`}
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then(
view,
)
openFileInNewTab(doc.id, doc.name)
}
>
<Eye size={15} />
@@ -1280,6 +1265,23 @@ export default function CustomerDetailPage() {
{/* DOCUMENTS */}
<Tabs.Panel value="documents" pt="lg">
<Stack gap="lg">
{/* Reviewing a customer means reading every document, so offer the
whole set at once — each opens in its own tab. The loop is
synchronous inside the click handler on purpose: that is what
keeps the browser treating all of them as user-initiated. */}
<Group justify="flex-end">
<Button
variant="light"
leftSection={<ExternalLink size={16} />}
disabled={documents.length === 0}
onClick={() =>
documents.forEach((d) => openFileInNewTab(d.id, d.name))
}
>
Open all {documents.length > 0 && `(${documents.length})`}
</Button>
</Group>
<TableCard minWidth={760}>
<DataTable
columns={documentColumns}
@@ -1316,9 +1318,7 @@ export default function CustomerDetailPage() {
<Anchor
component="button"
type="button"
onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
onClick={() => openFileInNewTab(f.id, f.name)}
size="xs"
style={{
textDecoration:
@@ -1424,7 +1424,6 @@ export default function CustomerDetailPage() {
onClose={() => setChangeRequestDoc(null)}
/>
{viewer}
</PageContainer>
);
}

View File

@@ -1,7 +1,5 @@
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { Box, Button, Card, Container, Group, Modal, SegmentedControl, Select, Stack, Text, TextInput, Title } from "@mantine/core";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -13,7 +11,7 @@ import {
FREIGHT_PERMS,
} from "@/lib/permissions";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Inbox, Plus, Warehouse } from "lucide-react";
import { Inbox, LayoutGrid, Plus, Table2, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, Navigate, useLocation } from "react-router-dom";
@@ -21,13 +19,12 @@ import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { matchesDayRange } from "@/hooks/useListControls";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import WagonStatusActions from "@/components/wagons/WagonStatusActions";
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { useFleetViewMode, type FleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useToast } from "@/hooks/use-toast";
import {
@@ -43,11 +40,46 @@ import {
type FleetListFilters,
type FleetRecord,
} from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { useDebouncedValue } from "@mantine/hooks";
import { DataTable, DataTableFooter } from "@edr/ui-common";
import { dateRangeParams, FilterBar, useFilters, type FilterDef, type FilterOption } from "@/components/filters";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
const SERVER_FILTERED_SLUGS: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"];
// trains/containers/cargoes have no server-side `listFilters` config (see
// resources.ts) — they get a plain client-only Status filter instead, off a
// fixed enum rather than "whatever status happens to exist in the currently
// loaded rows" (which would create a circular dependency: filterDefs feeds
// useFilters, which feeds the query that produces those rows).
const TRAIN_STATUS_OPTIONS: FilterOption[] = [
{ value: "AVAILABLE", label: "Available" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "IN_SERVICE", label: "In service" },
{ value: "UNDER_MAINTENANCE", label: "Under maintenance" },
{ value: "OUT_OF_SERVICE", label: "Out of service" },
{ value: "DEACTIVATED", label: "Deactivated" },
];
const CONTAINER_STATUS_OPTIONS: FilterOption[] = [
{ value: "AVAILABLE", label: "Available" },
{ value: "LOADED", label: "Loaded" },
{ value: "IN_TRANSIT", label: "In transit" },
{ value: "MAINTENANCE", label: "Maintenance" },
{ value: "DAMAGED", label: "Damaged" },
];
const CARGO_STATUS_OPTIONS: FilterOption[] = [
{ value: "PENDING", label: "Pending" },
{ value: "LOADED", label: "Loaded" },
{ value: "IN_TRANSIT", label: "In transit" },
{ value: "DELIVERED", label: "Delivered" },
{ value: "UNLOADED", label: "Unloaded" },
];
const FALLBACK_STATUS_OPTIONS: Partial<Record<FleetResourceSlug, FilterOption[]>> = {
trains: TRAIN_STATUS_OPTIONS,
containers: CONTAINER_STATUS_OPTIONS,
cargoes: CARGO_STATUS_OPTIONS,
};
const FleetResourcePage = () => {
const location = useLocation();
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
@@ -70,18 +102,9 @@ const FleetResourcePage = () => {
hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) ||
hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
// Wagons and locomotives page in the database; the rest still list in full
// and page in the browser (see `pagedHandlers` in fleet.service).
const serverPaged = isFleetServerPaginated(slug);
const [statusFilter, setStatusFilter] = useState("ALL");
// Registration date range. Server-side list filters (status/yard/train) are
// applied by the API; this narrows what comes back, alongside search.
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
@@ -95,77 +118,6 @@ const FleetResourcePage = () => {
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
const { viewMode, setViewMode } = useFleetViewMode(slug);
const serverListFilters = useMemo((): FleetListFilters | undefined => {
const serverFilteredSlugs: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"];
if (!serverFilteredSlugs.includes(slug)) return undefined;
const filters: FleetListFilters = {};
const status = listFilterValues.status;
const currentYardId = listFilterValues.currentYardId;
const availability = listFilterValues.availability;
const trainNumber = listFilterValues.trainNumber;
const trainId = listFilterValues.trainId;
if (status && status !== "ALL") {
(filters as { status?: string }).status = status;
}
if (currentYardId && currentYardId !== "ALL") {
filters.currentYardId = currentYardId;
}
if (availability && availability !== "ALL") {
(filters as { availability?: string }).availability = availability;
}
if (trainNumber && trainNumber !== "ALL") {
(filters as { trainNumber?: string }).trainNumber = trainNumber;
}
if (trainId && trainId !== "ALL") {
filters.trainId = trainId;
}
// Wagons only: narrow the fleet to one wagon type (the API filters on it).
const wagonTypeId = listFilterValues.wagonTypeId;
if (wagonTypeId && wagonTypeId !== "ALL") {
(filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId;
}
// The plain locomotives list has no server-side search — its page window
// does, so the term is only sent on the paginated path.
if ((serverPaged || slug !== "locomotives") && debouncedSearch.trim()) {
filters.search = debouncedSearch.trim();
}
return filters;
}, [slug, listFilterValues, debouncedSearch, serverPaged]);
// On the server-paged path the page window, the search and the registration
// date range are all resolved by the API — nothing is filtered client-side.
const pagedFilters = useMemo(
(): FleetListFilters => ({
...serverListFilters,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(dateFrom ? { createdFrom: dateFrom } : {}),
...(dateTo ? { createdTo: dateTo } : {}),
}),
[serverListFilters, pagination.pageIndex, pagination.pageSize, dateFrom, dateTo],
);
const listQuery = useQuery({
...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
enabled: !serverPaged,
});
const pagedQuery = useQuery({
...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }),
enabled: serverPaged,
placeholderData: keepPreviousData,
});
const activeQuery = serverPaged ? pagedQuery : listQuery;
const { isLoading, isError, error } = activeQuery;
const allRows = useMemo(
() => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])),
[serverPaged, pagedQuery.data, listQuery.data],
);
const create = useMutation(api.fleet.create.mutationOptions());
const update = useMutation(api.fleet.update.mutationOptions());
const remove = useMutation(api.fleet.remove.mutationOptions());
const purge = useMutation(api.fleet.purge.mutationOptions());
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
api.wagonTypes.list.queryOptions(),
);
@@ -202,40 +154,8 @@ const FleetResourcePage = () => {
enabled: slug === "wagons",
});
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
setStatusFilter("ALL");
setListFilterValues({});
}, [slug, setPagination]);
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
}, [search, listFilterValues, dateFrom, dateTo, setPagination]);
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
const usesServerListFilters = Boolean(config?.listFilters?.length);
const statusFilterOptions = useMemo(() => {
if (!hasStatusColumn || usesServerListFilters) return [];
if (slug === "vehicles" || slug === "drivers") {
return [
{ value: "ALL", label: "All statuses" },
{ value: "ACTIVE", label: "Active" },
{ value: "INACTIVE", label: "Inactive" },
];
}
const statuses = new Set(
allRows
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
.filter(Boolean),
);
return [
{ value: "ALL", label: "All statuses" },
...[...statuses].sort().map((status) => ({ value: status, label: status })),
];
}, [allRows, hasStatusColumn, usesServerListFilters, slug]);
const dynamicOptions = useMemo(() => {
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
(t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }),
@@ -291,25 +211,78 @@ const FleetResourcePage = () => {
};
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
return config.listFilters.map((filter) => {
const dynamicOpts = filter.dynamicOptions
? (dynamicOptions[filter.dynamicOptions] ?? [])
: [];
const staticOpts =
filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? [];
const opts = filter.dynamicOptions ? dynamicOpts : staticOpts;
return {
...filter,
value: listFilterValues[filter.key] ?? "ALL",
data: [
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
...opts,
],
};
});
}, [config?.listFilters, listFilterValues, dynamicOptions]);
// One pill per configured server list filter (status/yard/wagon type/train…),
// built off `config.listFilters` — same source the old plain `<Select>` row
// read, just reshaped into FilterDefs. Falls back to a plain client-only
// Status filter for the 3 slugs with no server-side list filters at all.
const filterDefs: FilterDef[] = useMemo(() => {
const dateDef: FilterDef = {
key: "created",
label: "Registered",
type: "date",
secondary: true,
toParams: dateRangeParams("createdFrom", "createdTo"),
};
if (config?.listFilters?.length) {
return [
...config.listFilters.map((filter): FilterDef => ({
key: filter.key,
label: filter.label,
type: "enum",
multiple: false,
options: filter.dynamicOptions
? (dynamicOptions[filter.dynamicOptions] ?? [])
: (filter.options ?? []),
})),
dateDef,
];
}
const fallback = FALLBACK_STATUS_OPTIONS[slug];
return fallback
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef]
: [dateDef];
}, [config, dynamicOptions, slug]);
const controls = useFilters(filterDefs, { pageSize: 10 });
// On the server-paged path the page window, the search and the registration
// date range are all resolved by the API — nothing is filtered client-side.
// `controls.params` already carries every filter's mapped param name (status/
// currentYardId/wagonTypeId/… default to `{key: value}`, "created" maps to
// createdFrom/createdTo) plus search/page/pageSize — it IS the paged filter
// object; the unpaged one is the same minus pagination and the date range
// (which stays client-only for the non-server-paged slugs, see below).
const serverListFilters = useMemo((): FleetListFilters | undefined => {
if (!SERVER_FILTERED_SLUGS.includes(slug)) return undefined;
const { page: _page, pageSize: _pageSize, createdFrom: _cf, createdTo: _ct, ...rest } = controls.params;
return rest as FleetListFilters;
}, [slug, controls.params]);
const pagedFilters = useMemo(
(): FleetListFilters => controls.params as unknown as FleetListFilters,
[controls.params],
);
const listQuery = useQuery({
...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
enabled: !serverPaged,
});
const pagedQuery = useQuery({
...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }),
enabled: serverPaged,
placeholderData: keepPreviousData,
});
const activeQuery = serverPaged ? pagedQuery : listQuery;
const { isLoading, isError, error } = activeQuery;
const allRows = useMemo(
() => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])),
[serverPaged, pagedQuery.data, listQuery.data],
);
const create = useMutation(api.fleet.create.mutationOptions());
const update = useMutation(api.fleet.update.mutationOptions());
const remove = useMutation(api.fleet.remove.mutationOptions());
const purge = useMutation(api.fleet.purge.mutationOptions());
useEffect(() => {
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
@@ -349,16 +322,22 @@ const FleetResourcePage = () => {
// The API already applied every filter and cut the page — re-filtering here
// would drop rows the server deliberately returned.
if (serverPaged) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
// The date range applies even when the API already filtered the list —
// it is not one of the server-side filters.
if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false;
if (usesServerListFilters) return true;
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
const created = controls.values.created;
if (created && !matchesDayRange(record.createdAt, created.v[0]?.slice(0, 10) ?? null, created.v[1]?.slice(0, 10) ?? null)) {
return false;
}
// Every other filter (status/yard/wagon type/…) was already applied
// server-side for these slugs — re-checking here against a plain field
// equality would be wrong for one (a wagon's "trainNumber" filter
// matches either of two DIFFERENT columns server-side, not one).
if (usesServerListFilters) return true;
const status = controls.values.status;
if (status && String(record.status ?? "") !== status.v[0]) return false;
const term = controls.searchText.trim().toLowerCase();
if (!term) return true;
return config.searchKeys.some((key) =>
String(record[key] ?? "")
@@ -366,19 +345,23 @@ const FleetResourcePage = () => {
.includes(term),
);
});
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]);
}, [allRows, config, usesServerListFilters, controls.values, controls.searchText, serverPaged]);
const totalCount = serverPaged
? (pagedQuery.data?.meta.total ?? 0)
: filteredRows.length;
const pageCount = serverPaged
? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1)
: Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
: Math.max(1, Math.ceil(filteredRows.length / controls.pageSize));
const pagedRows = useMemo(() => {
if (serverPaged) return filteredRows;
const start = pagination.pageIndex * pagination.pageSize;
return filteredRows.slice(start, start + pagination.pageSize);
}, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]);
const start = (controls.page - 1) * controls.pageSize;
return filteredRows.slice(start, start + controls.pageSize);
}, [filteredRows, controls.page, controls.pageSize, serverPaged]);
// Same {pagination, tableOptions} shape DataTable takes directly; FleetCardGrid
// (not a DataTable) just needs the raw pieces out of it below.
const { pagination: dtPagination, tableOptions: dtTableOptions } = controls.tableProps(totalCount);
const columns = useMemo((): ColumnDef<FleetRecord>[] => {
if (!config) return [];
@@ -604,77 +587,41 @@ const FleetResourcePage = () => {
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="md" w="100%" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
<FleetToolbar
search={search}
onSearchChange={setSearch}
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder={config.searchPlaceholder}
showSearch={config.supportsSearch}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<Group gap="sm" wrap="wrap" align="center">
<DatePickerInput
type="range"
aria-label="Created date range"
placeholder="Created date range"
value={[dateFrom, dateTo]}
onChange={([from, to]) => {
setDateFrom(from);
setDateTo(to);
}}
presets={getDateRangePresets()}
clearable
size="sm"
radius="lg"
w={240}
/>
{listFilterSelects ? (
<Group gap="sm" wrap="wrap" align="center">
{listFilterSelects.map((filter) => (
<Select
key={filter.key}
aria-label={filter.label}
placeholder={filter.data[0]?.label ?? filter.label}
data={filter.data}
value={filter.value}
onChange={(value) => {
setListFilterValues((prev) => ({
...prev,
[filter.key]: value ?? "ALL",
}));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
size="sm"
radius="lg"
w={200}
searchable={filter.data.length > 8}
comboboxProps={{ withinPortal: true }}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
))}
</Group>
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
<Group gap={4} wrap="wrap">
<Text size="xs" fw={500} c="dimmed">Status:</Text>
<Group gap={4} wrap="wrap">
{[{ value: "ALL", label: "All" }, ...statusFilterOptions].map((option) => (
<Button
key={option.value}
size="xs"
radius="md"
variant={statusFilter === option.value ? "filled" : "outline"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setStatusFilter(option.value)}
>
{option.label}
</Button>
))}
</Group>
</Group>
) : null}
</Group>
}
/>
viewId={`fleet-${slug}`}
>
<SegmentedControl
value={viewMode}
onChange={(value) => setViewMode(value as FleetViewMode)}
size="sm"
radius="lg"
data={[
{
value: "table",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Table2 size={14} />
<span>Table</span>
</Group>
),
},
{
value: "cards",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<LayoutGrid size={14} />
<span>Cards</span>
</Group>
),
},
]}
styles={{ root: { background: "var(--mantine-color-gray-1)" } }}
/>
</FilterBar>
</Box>
{viewMode === "table" ? (
@@ -696,18 +643,8 @@ const FleetResourcePage = () => {
: undefined
}
emptyMessage={`No ${itemLabel} found`}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
pagination={dtPagination}
tableOptions={dtTableOptions}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
@@ -724,10 +661,10 @@ const FleetResourcePage = () => {
rows={pagedRows}
status={tableStatus}
emptyMessage={`No ${itemLabel} found`}
pagination={pagination}
pagination={{ pageIndex: controls.page - 1, pageSize: controls.pageSize }}
pageCount={pageCount}
totalCount={totalCount}
onPaginationChange={setPagination}
onPaginationChange={dtTableOptions!.onPaginationChange!}
onEdit={
canUpdate
? (record) => {

View File

@@ -29,13 +29,13 @@ const TABS = [
Panel: InvoicesPanel,
},
{
key: "usd-payments",
label: "USD Payments",
key: "manual-payments",
label: "Manual Payments",
icon: Landmark,
// Same gate as Invoices, not a dedicated key — mirrors the old route.
permission: FREIGHT_PERMS.invoices.view,
subtitle:
"USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes.",
"Import and export invoices in USD or ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: UsdPaymentsPanel,
},
] as const;

View File

@@ -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}>

View File

@@ -11,6 +11,7 @@ import {
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
@@ -55,14 +56,19 @@ function formatRemaining(deadlineMs: number, now: number): string | null {
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
function PayWindowCell({ deadline }: { deadline: string | null }) {
/** Ticks once a second while a deadline is set, so window state updates live. */
function useNow(deadline: string | null): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!deadline) return;
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [deadline]);
return now;
}
function PayWindowCell({ deadline }: { deadline: string | null }) {
const now = useNow(deadline);
if (!deadline) {
return (
@@ -88,13 +94,56 @@ function PayWindowCell({ deadline }: { deadline: string | null }) {
);
}
/** True once the pay window has closed — the API refuses confirmation then. */
function windowClosed(row: OfflineUsdInvoice): boolean {
const deadline = row.booking?.paymentDeadline;
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
/**
* "Confirm paid" for one row. Booking invoices are only confirmable while the
* booking's pay window is open (the API refuses otherwise): no window yet →
* no button; window closed → button disabled with the reason, and it flips
* live the second the countdown hits zero. Non-booking invoices (warehouse,
* clearance…) have no window and stay confirmable.
*/
function ConfirmCell({
row,
onConfirm,
}: {
row: OfflineUsdInvoice;
onConfirm: (row: OfflineUsdInvoice) => void;
}) {
const deadline = row.booking?.paymentDeadline ?? null;
const now = useNow(deadline);
if (row.booking && !deadline) return null;
const closed = Boolean(deadline && new Date(deadline).getTime() <= now);
return (
<Tooltip
label="Pay window closed — the booking can no longer be confirmed as paid."
disabled={!closed}
withArrow
>
<span>
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={closed}
onClick={(e) => {
e.stopPropagation();
onConfirm(row);
}}
>
Confirm paid
</Button>
</span>
</Tooltip>
);
}
/** USD Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */
/**
* Manual Payments tab body of `FinanceHubPage` — page chrome lives in the
* parent. Lists open USD and ETB invoices (import and export alike) that
* Finance settles by hand; confirming records the payment the same way an
* online payment would, so the booking advances identically.
*/
export default function UsdPaymentsPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -103,6 +152,7 @@ export default function UsdPaymentsPanel() {
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(null);
const [reference, setReference] = useState("");
@@ -119,8 +169,15 @@ export default function UsdPaymentsPanel() {
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
currency: currency || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
[
pagination.pageIndex,
pagination.pageSize,
debouncedQuery,
statusFilter,
currency,
],
);
const { data, isLoading, isError, refetch, isFetching } = useQuery(
@@ -170,7 +227,9 @@ export default function UsdPaymentsPanel() {
header: "Customer",
cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ?? "—"}
{row.original.company?.name ??
row.original.shippingLineCompany?.name ??
"—"}
</Text>
),
},
@@ -179,6 +238,28 @@ export default function UsdPaymentsPanel() {
header: "Booking",
cell: ({ row }) => {
const booking = row.original.booking;
const bookings = row.original.bookings ?? [];
if (!booking && bookings.length) {
// Shipping-line credit invoice: one link per billed booking.
return (
<Group gap={4} wrap="wrap" maw={280}>
{bookings.map((b) => (
<Button
key={b.id}
variant="subtle"
size="compact-xs"
rightSection={<ExternalLink size={11} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${b.id}`);
}}
>
{b.reference}
</Button>
))}
</Group>
);
}
if (!booking) {
return (
<Text size="sm" c="dimmed">
@@ -187,20 +268,41 @@ export default function UsdPaymentsPanel() {
);
}
return (
<Button
variant="subtle"
size="compact-sm"
rightSection={<ExternalLink size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${booking.id}`);
}}
>
{booking.reference}
</Button>
<Group gap={6} wrap="nowrap">
<Button
variant="subtle"
size="compact-sm"
rightSection={<ExternalLink size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${booking.id}`);
}}
>
{booking.reference}
</Button>
{booking.tradeDirection && (
<Badge size="xs" variant="light" radius="sm" color="gray">
{humanize(booking.tradeDirection)}
</Badge>
)}
</Group>
);
},
},
{
id: "currency",
header: "Currency",
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
radius="sm"
color={row.original.currency?.toUpperCase() === "USD" ? "blue" : "teal"}
>
{row.original.currency}
</Badge>
),
},
{
id: "status",
header: "Status",
@@ -239,22 +341,8 @@ export default function UsdPaymentsPanel() {
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => {
const paid = row.original.status === "PAID";
if (paid || !canConfirm) return null;
return (
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={windowClosed(row.original)}
onClick={(e) => {
e.stopPropagation();
setConfirming(row.original);
}}
>
Confirm paid
</Button>
);
if (row.original.status === "PAID" || !canConfirm) return null;
return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
},
},
],
@@ -288,6 +376,20 @@ export default function UsdPaymentsPanel() {
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={currency || "all"}
onChange={(v) => {
setCurrency(v === "all" ? "" : (v as "USD" | "ETB"));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
]}
/>
<SegmentedControl
size="sm"
radius="md"
@@ -318,7 +420,7 @@ export default function UsdPaymentsPanel() {
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={1040}>
<Box miw={1160}>
<DataTable
columns={columns}
data={rows}
@@ -326,13 +428,13 @@ export default function UsdPaymentsPanel() {
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No USD invoices match your search."
: "No USD invoices awaiting confirmation."
? "No invoices match your search."
: "No invoices awaiting manual payment confirmation."
}
error={
isError
? {
message: "Failed to load USD invoices.",
message: "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
@@ -361,7 +463,7 @@ export default function UsdPaymentsPanel() {
opened={confirming !== null}
onClose={closeConfirm}
title={
<Text fw={700}>Confirm bank transfer payment</Text>
<Text fw={700}>Confirm manual payment</Text>
}
radius="md"
size="md"
@@ -371,20 +473,21 @@ export default function UsdPaymentsPanel() {
<Text size="sm" c="dimmed">
Confirming settles {confirming.invoiceNumber} in full (
{formatMoney(confirming.balanceAmount, confirming.currency)}) and
marks the booking as paid. Upload the customer&apos;s bank slip
first this cannot be undone.
marks the booking as paid exactly as if the customer had paid
online. Upload the customer&apos;s bank slip or receipt first
this cannot be undone.
</Text>
<PhasedFileDropzone
label="Bank payment slip"
description="PDF or image of the customer's transfer slip."
label="Payment slip / receipt"
description="PDF or image of the customer's bank transfer slip or payment receipt."
value={slip}
onChange={setSlip}
/>
<TextInput
label="Bank reference"
description="Optional — the transfer reference from the slip."
label="Payment reference"
description="Optional — the transfer or receipt reference from the slip."
placeholder="e.g. FT24091234567"
value={reference}
onChange={(e) => setReference(e.target.value)}

View File

@@ -961,6 +961,7 @@ interface StandaloneReturnModalProps {
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
const [containerNumber, setContainerNumber] = useState<string>("");
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [warehouse, setWarehouse] = useState<string | null>(null);
@@ -1021,6 +1022,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
containers: [
{
containerNumber,
containerSize: containerSize ?? undefined,
returnDate,
warehouse: selectedWarehouse?.name || warehouse,
yard: selectedYard?.name,
@@ -1034,6 +1036,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
});
setContainerNumber("");
setContainerSize(null);
setReturnedBy(null);
setReturnDate(new Date().toISOString().split("T")[0]);
setWarehouse(null);
@@ -1071,6 +1074,17 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
required
/>
<Select
label="Container Type"
placeholder="Select container size"
value={containerSize}
onChange={(val) => setContainerSize(val as EmptyContainerSize | null)}
data={[
{ value: "20", label: "20 ft" },
{ value: "40", label: "40 ft" },
]}
/>
<Select
label="Return Warehouse"
placeholder="Select warehouse for container return"

View File

@@ -25,6 +25,53 @@ export async function downloadBookingFile(
URL.revokeObjectURL(url);
}
/**
* Open a stored file in its own browser tab.
*
* Two things make this less trivial than an `<a target="_blank">`:
* - `GET /files/:id` is authenticated, so the bytes have to come through the
* axios client and be handed over as a blob URL (same reason as
* {@link fetchViewableFile}).
* - The tab therefore has to be opened *synchronously*, inside the click
* gesture, and filled once the download resolves — a `window.open()` after an
* `await` is blocked as a popup. That also means a loop over several
* documents opens one tab each, all within the same gesture.
*
* `noopener` is deliberately not passed: it makes `window.open` return null, and
* the handle is what lets us navigate the tab. `opener` is nulled instead.
*/
export function openFileInNewTab(id: string, filename: string): void {
const tab = window.open("", "_blank");
if (tab) {
tab.opener = null;
tab.document.title = filename;
if (tab.document.body) {
tab.document.body.textContent = `Opening ${filename}`;
}
}
void filesService.download(id).then(
(blob) => {
const url = URL.createObjectURL(blob);
if (tab) tab.location.replace(url);
// Popup blocked — fall back to a save, so the click still does something.
else {
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
}
// Revoking immediately would cancel the tab's own load of the URL.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
},
(error: unknown) => {
if (tab?.document.body) {
tab.document.body.textContent = `Could not open ${filename}.`;
}
console.error(`Failed to open file ${id}`, error);
},
);
}
/**
* GET /files/:id is authenticated (global JwtGuard) — raw browser loads
* (<img>/<iframe>/<a href>) carry no Bearer token and 401. Fetch the bytes

View File

@@ -59,7 +59,7 @@ export const invoicesService = {
.then((r) => r.data);
},
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
/** Finance worklist: USD and ETB invoices awaiting manual payment confirmation. */
listOfflineUsd(
filter: InvoiceListFilter,
): Promise<PaginatedOfflineUsdInvoices> {
@@ -70,7 +70,7 @@ export const invoicesService = {
.then((r) => r.data);
},
/** Confirm a USD invoice paid by bank transfer — the slip file is required. */
/** Confirm an invoice (USD or ETB) paid manually — the slip file is required. */
confirmOffline(id: string, file: File, reference?: string): Promise<Invoice> {
const body = new FormData();
body.append("file", file);

View File

@@ -111,7 +111,11 @@ export interface CompanyChangeRequest {
/** Staged company-document add/remove intents (e.g. the PoA letter). */
documentChanges: DocumentChangeIntent[];
note: string | null;
/** Who filed the request — resolved from `iam.users`, null when unknown. */
submittedByName: string | null;
submittedAt: string | null;
/** Who approved / rejected / sent it back. */
reviewedByName: string | null;
reviewedAt: string | null;
createdAt: string;
updatedAt: string;
@@ -139,6 +143,8 @@ export interface CompanyRevision {
id: string;
companyId: string;
actorId: string | null;
/** Who made the edit — resolved from `iam.users`, null when unknown. */
actorName: string | null;
summary: string;
changes: CompanyRevisionChange[];
createdAt: string;

View File

@@ -13,6 +13,8 @@ export interface InvoiceListFilter {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
/** Manual-payments worklist only. */
currency?: "USD" | "ETB";
}
/** Standard paginated list envelope (matches the customers/bookings service shape). */
@@ -22,17 +24,21 @@ export interface PaginatedInvoices {
}
/**
* A USD invoice on Finance's offline-settlement worklist. Booking-sourced rows
* carry the shipment's pay-window deadline so the list can show the same
* countdown the customer sees — Finance must confirm before it closes.
* A USD or ETB invoice on Finance's manual-settlement worklist. Booking-sourced
* rows carry the shipment's trade direction and pay-window deadline so the list
* can show the same countdown the customer sees — Finance must confirm before
* it closes.
*/
export interface OfflineUsdInvoice extends Invoice {
booking: {
id: string;
reference: string;
tradeDirection: string | null;
paymentDeadline: string | null;
paymentStatus: string;
} | null;
/** Shipping-line credit invoices span many bookings — one entry per credit. */
bookings: { id: string; reference: string; tradeDirection: string | null }[];
}
export interface PaginatedOfflineUsdInvoices {