mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
Merge branch 'freight/nati-2' into freight/feat/element-chat
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}>
|
||||
{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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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's bank slip
|
||||
first — this cannot be undone.
|
||||
marks the booking as paid — exactly as if the customer had paid
|
||||
online. Upload the customer'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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user