feat: add pagination to schedule history and consolidation approvals

- Implemented pagination in ScheduleHistoryPanel to manage large history entries.
- Updated API to support pagination parameters for schedule history.
- Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows.
- Introduced new types for paginated responses in bookings and train scheduling services.
- Added a database migration to create an index on wagon_booking_allocations for performance improvements.
This commit is contained in:
Marshal
2026-08-23 04:51:34 +00:00
92 changed files with 3751 additions and 1528 deletions

View File

@@ -16,6 +16,7 @@ import {
Textarea,
Tooltip,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import {
Ban,
Download,
@@ -32,7 +33,7 @@ import { isViewable } from "@edr/ui-common";
import { bookingsService } from "@/services/bookings.service";
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
import { formatDateTime } from "@/lib/format";
import { formatDate, formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"];
@@ -75,6 +76,7 @@ export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPayme
currency: string;
action: "draft" | "send";
file?: File | null;
dueDate?: string | null;
}) => bookingsService.createAdditionalCharge(bookingId, p),
onSuccess: (next, p) => {
toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved");
@@ -203,16 +205,29 @@ function ChargeCard({
{charge.cancelReason ? `${charge.cancelReason}` : ""}
</Text>
)}
{charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && (
<Text fz="11.5px" c="dimmed">
Due {formatDate(charge.dueAt)}
</Text>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<Text fz="14px" fw={800} c="edr-text">
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.currency}
</Text>
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
<Group gap={8} wrap="nowrap" align="flex-end" style={{ flexDirection: "column" }}>
<Group gap={8} wrap="nowrap">
<Text fz="14px" fw={800} c="edr-text">
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.currency}
</Text>
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
</Group>
{charge.convertedAmount != null && (
<Text fz="11.5px" c="dimmed">
{charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.convertedCurrency}
</Text>
)}
</Group>
</Group>
@@ -297,12 +312,14 @@ function AddChargeModal({
currency: string;
action: "draft" | "send";
file?: File | null;
dueDate?: string | null;
}) => void;
}) {
const [reason, setReason] = useState("");
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [file, setFile] = useState<File | null>(null);
const [dueDate, setDueDate] = useState<Date | null>(null);
const valid = reason.trim().length > 0 && Number(amount) > 0;
@@ -311,11 +328,23 @@ function AddChargeModal({
setAmount("");
setCurrency("ETB");
setFile(null);
setDueDate(null);
};
const submit = (action: "draft" | "send") => {
if (!valid) return;
onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file });
onSubmit({
reason: reason.trim(),
amount: Number(amount),
currency,
action,
file,
// Local calendar date, not a UTC-shifted ISO timestamp — toISOString() can
// roll the date back a day for evening local time in a positive-offset zone.
dueDate: dueDate
? `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, "0")}-${String(dueDate.getDate()).padStart(2, "0")}`
: null,
});
};
return (
@@ -355,6 +384,14 @@ function AddChargeModal({
w={100}
/>
</Group>
<DateInput
label="Due date"
placeholder="Defaults to 14 days after sending"
value={dueDate}
onChange={(v) => setDueDate(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
<FileButton onChange={setFile} accept="application/pdf,image/*">
{(props) => (
<Button

View File

@@ -1,6 +1,3 @@
import type { AuthUser } from "@/auth/types";
import { getPositionKeys } from "@/lib/permissions";
/** One overview composition. Every backoffice user lands on exactly one of these. */
export type OverviewLayoutKey =
| "executive"
@@ -20,80 +17,35 @@ export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = {
};
/**
* Position/role key → layout, in match priority order: a user holding several
* of these keys gets the first match, so the specific operational view wins
* over the broad executive one. Roles are matched alongside positions because
* the IAM payload models the GL desks as positions (`ethiopian_gl`) on some
* accounts and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`.
*
* The `edr_freight_app/…` keys are the org's real position keys (root desks and
* their sub-positions) as configured under Unit → Departments. They are typed
* by hand in the Add/Edit Department form, so a new sub-position appears here
* only once someone adds it — unmapped keys fall through to `executive`.
* Priority order: a caller who holds more than one of the six
* `edr_freight_app:overview:<key>:view` permissions gets the FIRST match
* here — the specific operational view wins over the broad executive one.
* Mirrors `OVERVIEW_LAYOUT_KEYS` in the API's freight-permissions.registry.ts
* bit for bit; keep the two in sync if this ever changes.
*/
const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [
// ── Clearance & logistics: both GL desks, root and sub-positions ──────────
["ethiopian_gl", "clearance"],
["edr_freight_app/gl_003", "clearance"], // Ethiopian GL Chief
["edr_freight_app/off_001", "clearance"], // Ethiopian GL Director
["edr_freight_app/off_0056", "clearance"], // Ethiopian GL Officer
["djibouti_gl", "clearance"],
["edr_freight_app/dj_gl_001", "clearance"], // Djibouti GL Director
["edr_freight_app/dj_gl_002", "clearance"], // Djibouti GL Chief
["edr_freight_app/dj_gl_003", "clearance"], // Djibouti GL Officer
["edr_gl_ethiopia", "clearance"], // legacy role form
["edr_gl_djibouti", "clearance"], // legacy role form
// ── Control centre ───────────────────────────────────────────────────────
["edr_freight_app/occ_001", "occ"], // OCC
["edr_freight_app/occ_005", "occ"], // OCC Director
["edr_line_staff", "occ"], // legacy role form
// ── Operations: operations desk, track & machinery, rolling stock ─────────
["edr_freight_app/opn", "operation"], // Operation
["edr_freight_app/opcf", "operation"], // Operation Chief
["edr_freight_app/opdr", "operation"], // Operation Director
["edr_freight_app/opco", "operation"], // Operation Officer
["edr_freight_app/opp_005", "operation"], // Operation Dispatcher
["edr_freight_app/opp_0067", "operation"], // Gelan Operation Director
["edr_freight_app/track_001", "operation"], // Track And Machinery
["edr_freight_app/ttk_001", "operation"], // Track Director
["edr_freight_app/tto_001", "operation"], // Track Operator
["edr_freight_app/rool_001", "operation"], // Rolling Stock
["edr_freight_app/rl_003", "operation"], // Rolling Stock Director
["edr_freight_app/rl_009", "operation"], // Rolling Stock Team Lead
["edr_freight_app/rl_0090", "operation"], // Rolling Stock Dispatcher
["operation", "operation"],
["operations_chief", "operation"],
["dispatcher", "operation"],
["truck_machinery_chief", "operation"],
["edr_operations_officer", "operation"], // legacy role form
// ── Marketing ────────────────────────────────────────────────────────────
["edr_freight_app/edr_test_org_0022", "marketer"], // Commercial Marketing
["edr_freight_app/edr_test_org_00567", "marketer"], // Marketing Director
["edr_freight_app/edr_test_org_0054", "marketer"], // Marketing Chief
["edr_freight_app/edr_test_org_0013", "marketer"], // Marketing Officer
["marketer", "marketer"],
["edr_marketing", "marketer"], // legacy role form
// ── Finance ──────────────────────────────────────────────────────────────
["edr_freight_app/finance", "finance"],
["edr_finance", "finance"], // legacy role form
// ── Executive: org-wide desks with no operational queue of their own ──────
["ceo", "executive"],
["director", "executive"],
["chief", "executive"],
["edr_ceo", "executive"], // legacy role form
["edr_director", "executive"], // legacy role form
["edr_org_manager", "executive"], // legacy role form
const LAYOUT_PRIORITY: OverviewLayoutKey[] = [
"clearance",
"occ",
"operation",
"marketer",
"finance",
"executive",
];
/** Unmapped keys (superadmin, IAM admins, Safety, new positions) keep the executive layout. */
/**
* Which layout to render, given the keys `GET /overview/layouts` said the
* caller may see — the endpoint already filtered those by permission, so
* this only breaks the tie when a caller holds more than one. Same shape as
* the Reports page trusting `GET /reports`'s catalog rather than re-deriving
* access from permission keys client-side.
*
* Empty/unmapped falls back to the executive layout — same default the old
* role/position-key table used for superadmin, IAM admins, and any position
* that hasn't been granted one of these permissions yet.
*/
export function resolveOverviewLayout(
user: AuthUser | null | undefined,
allowed: OverviewLayoutKey[] | undefined,
): OverviewLayoutKey {
const held = new Set(getPositionKeys(user));
return ROLE_LAYOUTS.find(([key]) => held.has(key))?.[1] ?? "executive";
const held = new Set(allowed ?? []);
return LAYOUT_PRIORITY.find((key) => held.has(key)) ?? "executive";
}

View File

@@ -15,6 +15,8 @@ import {
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
@@ -130,6 +132,9 @@ export function IntercityRideAlongPanel({
direction: string | null | undefined;
}) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
@@ -378,12 +383,19 @@ export function IntercityRideAlongPanel({
<Table.Td>
<Group gap="xs" justify="flex-end">
{row.status === "PAID" && (
<Tooltip label="Train must be at the booking's origin yard">
<Tooltip
label={
canLoad
? "Train must be at the booking's origin yard"
: "You don't have permission to load cargo"
}
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
disabled={!canLoad}
onClick={() =>
load.mutate({ scheduleId, bookingId: row.id })
}
@@ -393,13 +405,20 @@ export function IntercityRideAlongPanel({
</Tooltip>
)}
{row.status === "IN_TRANSIT" && (
<Tooltip label="Train must be at the booking's destination yard">
<Tooltip
label={
canUnload
? "Train must be at the booking's destination yard"
: "You don't have permission to unload cargo"
}
>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
disabled={!canUnload}
onClick={() =>
unload.mutate({ scheduleId, bookingId: row.id })
}

View File

@@ -25,6 +25,8 @@ import { useEffect, useState } from "react";
import { Freight } from "@edr/types";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { TrackStation, YardWorkBookingRow } from "@/types/trainScheduling";
@@ -111,6 +113,8 @@ export function LogPassYardWorkModal({
alreadyLogged: boolean;
}) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const [justLogged, setJustLogged] = useState(false);
// When the train was here — defaults to now, past allowed (recorded after the fact).
const [passAt, setPassAt] = useState<Date | null>(null);
@@ -353,18 +357,20 @@ export function LogPassYardWorkModal({
{!row.loadedAt ? (
<Tooltip
label={
!logged
? "Log the pass first — the train must be at this yard"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
: "Confirm cargo loaded onto the train"
!canLoad
? "You don't have permission to load cargo"
: !logged
? "Log the pass first — the train must be at this yard"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
: "Confirm cargo loaded onto the train"
}
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!logged || !row.canLoad}
disabled={!canLoad || !logged || !row.canLoad}
loading={
load.isPending && load.variables?.bookingId === row.id
}

View File

@@ -38,6 +38,8 @@ import { CountdownTimer } from "@edr/ui-common";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { EntityLink } from "@/components/detail";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import { useToast } from "@/hooks/use-toast";
@@ -170,6 +172,9 @@ export function ScheduleWorkspacePanel({
onChanged,
}: ScheduleWorkspacePanelProps) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
const freightType: FreightType | undefined =
schedule.freightType === "CONTAINER" || schedule.freightType === "BULK"
@@ -838,13 +843,15 @@ export function ScheduleWorkspacePanel({
{showLoad ? (
<Tooltip
label={
boardHere
? `Load cargo onto the train at ${group.label}`
: passed
? `Train already passed ${group.label} — this cargo missed its stop`
: `Loads at ${group.label} — train is ${
trainAtLabel ? `at ${trainAtLabel}` : "not there yet"
}`
!canLoad
? "You don't have permission to load cargo"
: boardHere
? `Load cargo onto the train at ${group.label}`
: passed
? `Train already passed ${group.label} — this cargo missed its stop`
: `Loads at ${group.label} — train is ${
trainAtLabel ? `at ${trainAtLabel}` : "not there yet"
}`
}
withArrow
>
@@ -853,7 +860,7 @@ export function ScheduleWorkspacePanel({
variant="filled"
color="edr-green"
radius="md"
disabled={!boardHere}
disabled={!boardHere || !canLoad}
leftSection={<PackageCheck size={13} />}
loading={
loadJourney.isPending &&
@@ -869,7 +876,11 @@ export function ScheduleWorkspacePanel({
) : null}
{showTruckToTrain ? (
<Tooltip
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
label={
canLoad
? "Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
: "You don't have permission to load cargo"
}
withArrow
>
<Button
@@ -877,6 +888,7 @@ export function ScheduleWorkspacePanel({
variant="light"
color="blue"
radius="md"
disabled={!canLoad}
leftSection={<Truck size={13} />}
loading={truckToTrainPending === b.id}
onClick={() =>
@@ -894,9 +906,11 @@ export function ScheduleWorkspacePanel({
{showUnload ? (
<Tooltip
label={
alightHere
? "Unload at this yard — stamps the booking's arrival"
: "Unloads when the train reaches its destination yard"
!canUnload
? "You don't have permission to unload cargo"
: alightHere
? "Unload at this yard — stamps the booking's arrival"
: "Unloads when the train reaches its destination yard"
}
withArrow
>
@@ -905,7 +919,7 @@ export function ScheduleWorkspacePanel({
variant="light"
color="orange"
radius="md"
disabled={!alightHere}
disabled={!alightHere || !canUnload}
leftSection={<PackageOpen size={13} />}
loading={
unloadJourney.isPending &&

View File

@@ -51,6 +51,8 @@ import {
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/auth/useAuth';
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from '@/lib/permissions';
import { api } from '@/services/api';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useToast } from '@/hooks/use-toast';
@@ -1566,6 +1568,8 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/** Export items that passed inspection and are queued to be loaded onto a train. */
function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.load);
const { data: rows = [], isLoading } = useQuery(
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
);
@@ -1655,16 +1659,18 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
<><b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load</>
)}
</Text>
<Button
size="compact-sm"
variant="filled"
color="teal"
leftSection={<Truck size={14} />}
disabled={rows.length === 0}
onClick={() => setTrainPickerOpen(true)}
>
{selected.size > 0 ? `Load Selected (${selected.size})` : 'Auto Load Ready Items'}
</Button>
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad} withArrow>
<Button
size="compact-sm"
variant="filled"
color="teal"
leftSection={<Truck size={14} />}
disabled={rows.length === 0 || !canLoad}
onClick={() => setTrainPickerOpen(true)}
>
{selected.size > 0 ? `Load Selected (${selected.size})` : 'Auto Load Ready Items'}
</Button>
</Tooltip>
</Group>
<Modal
@@ -2305,6 +2311,8 @@ export function ImportArriveQueueTab({
onChanged?: () => void;
}) {
const { toast } = useToast();
const { user } = useAuth();
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.unload);
const { data: trains = [], isLoading } = useQuery(
api.warehouses.importArriveQueue.queryOptions({ enabled }),
);
@@ -2489,23 +2497,25 @@ export function ImportArriveQueueTab({
>
Open
</Button>
<Button
size="compact-xs"
color={fullyUnloaded ? 'gray' : 'indigo'}
leftSection={<Truck size={14} />}
loading={busyId === t.scheduleId}
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading}
onClick={() =>
setConfirmAction({
title: 'Auto unload train',
message: `Unload all arrived bookings from train ${t.trainNumber ?? t.scheduleId.slice(0, 8)} into their assigned warehouse locations?`,
confirmLabel: 'Unload train',
run: () => autoUnload(t),
})
}
>
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
</Button>
<Tooltip label={canUnload ? undefined : "You don't have permission to unload cargo"} disabled={canUnload} withArrow>
<Button
size="compact-xs"
color={fullyUnloaded ? 'gray' : 'indigo'}
leftSection={<Truck size={14} />}
loading={busyId === t.scheduleId}
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading || !canUnload}
onClick={() =>
setConfirmAction({
title: 'Auto unload train',
message: `Unload all arrived bookings from train ${t.trainNumber ?? t.scheduleId.slice(0, 8)} into their assigned warehouse locations?`,
confirmLabel: 'Unload train',
run: () => autoUnload(t),
})
}
>
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
</Button>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>

View File

@@ -235,6 +235,7 @@ export const QUERY_KEYS = {
OVERVIEW: {
ROOT: ["overview"] as const,
layouts: () => ["overview", "layouts"] as const,
dashboard: (range?: string) =>
["overview", "dashboard", range ?? "30d"] as const,
bookingsTab: (range?: string) =>

View File

@@ -184,6 +184,7 @@ export const URL_CONSTANTS = {
OVERVIEW: {
BASE: "/overview",
LAYOUTS: "/overview/layouts",
BOOKINGS: "/overview/bookings",
CONTRACTS: "/overview/contracts",
BILLING: "/overview/billing",

View File

@@ -11,6 +11,15 @@ export function useOverview(range: OverviewRange = "30d") {
});
}
/** Layouts the caller may render — server-filtered by permission, same shape as useReports' catalog. */
export function useOverviewLayouts() {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.layouts(),
queryFn: () => overviewService.getLayouts(),
staleTime: 5 * 60 * 1000,
});
}
export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.bookingsTab(range),

View File

@@ -104,6 +104,9 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:train_scheduling:view",
create: "edr_freight_app:train_scheduling:create",
update: "edr_freight_app:train_scheduling:update",
/** Confirm cargo loaded/unloaded at a yard — import, export, and intercity alike. */
load: "edr_freight_app:train_scheduling:load",
unload: "edr_freight_app:train_scheduling:unload",
cancel: "edr_freight_app:train_scheduling:cancel",
reschedule: "edr_freight_app:train_scheduling:reschedule",
rulesManage: "edr_freight_app:train_scheduling:rules_manage",

View File

@@ -4,7 +4,6 @@ import {
Box,
Card,
Group,
SegmentedControl,
Stack,
Text,
Tooltip,
@@ -22,7 +21,7 @@ import {
ShieldOff,
Users,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
@@ -31,49 +30,22 @@ import {
ManualRegistrationBadge,
ProfileChips,
formatDate,
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company, CompanyStatus } from "@/types/customer";
import type { Company, CompanyListFilter } from "@/types/customer";
import { isOnboardingDraft } from "@/types/customer";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
import {
FilterBar,
dateRangeParams,
isoToLocalDateStr,
useFilters,
type FilterDef,
} from "@/components/filters";
import { ExportButton } from "@/components/export/ExportButton";
/**
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
* review, so it excludes drafts — a company row exists from the onboarding
* wizard's first click and would otherwise pad the review queue. Those drafts
* get their own view instead of disappearing, so staff can still chase them.
*/
type CustomerView =
| "all"
| "pending"
| "pendingChanges"
| "onboarding"
| "active";
/**
* "Pending changes" is deliberately not folded into "Pending approval". A
* customer who edits their profile after being approved stays `status = active`,
* so the pending filter can never match them — their resubmission would only
* ever be visible by opening their detail page. This view is that queue.
*/
const VIEW_FILTERS: Record<
CustomerView,
{
status?: CompanyStatus;
onboardingCompleted?: boolean;
hasPendingChangeRequest?: boolean;
}
> = {
all: {},
pending: { status: "pending", onboardingCompleted: true },
pendingChanges: { hasPendingChangeRequest: true },
onboarding: { onboardingCompleted: false },
active: { status: "active" },
};
const SORT_OPTIONS = [
// Queue ordering: awaiting first approval → pending profile changes → the
// rest, newest first within each group. The default, so whatever marketing
@@ -85,29 +57,110 @@ const SORT_OPTIONS = [
{ value: "name:DESC", label: "Name (ZA)" },
] as const;
/** No filter pills — search/sort/page are the only real filter dimensions;
* `view` below is a tab (mutually exclusive, navigational), not a filter. */
const NO_FILTER_DEFS: FilterDef[] = [];
/**
* Every state a customer can be in, as one single-select list.
*
* Three of these are not `companies.status` values at all, which is why each
* option maps its own params:
* - **Pending approval** is submitted-and-awaiting-review, so it excludes
* drafts — a company row exists from the onboarding wizard's first click and
* would otherwise pad the review queue.
* - **Onboarding** is that draft: still in the portal wizard, never submitted.
* - **Pending changes** is an already-approved (`active`) customer who edited
* their profile. `status` can never match them, so without this option their
* resubmission is only visible by opening their detail page.
*/
const STATUS_OPTIONS: {
value: string;
label: string;
params: Record<string, string>;
}[] = [
{ value: "pending", label: "Pending approval", params: { status: "pending", onboardingCompleted: "true" } },
{ value: "pendingChanges", label: "Pending changes", params: { hasPendingChangeRequest: "true" } },
{ value: "onboarding", label: "Onboarding", params: { onboardingCompleted: "false" } },
{ value: "active", label: "Active", params: { status: "active" } },
{ value: "suspended", label: "Suspended", params: { status: "suspended" } },
{ value: "blacklisted", label: "Blacklisted", params: { status: "blacklisted" } },
];
/**
* Filter pills. The review queues that used to sit beside them as segmented
* tabs are folded into the Status pill above — three of the five were never a
* plain `status` value, so as a separate tab strip they could contradict the
* status filter next to them. One list, mutually exclusive, no contradiction.
*/
const CUSTOMER_FILTER_DEFS: FilterDef[] = [
{
key: "status",
label: "Status",
type: "enum",
multiple: false,
options: STATUS_OPTIONS.map(({ value, label }) => ({ value, label })),
toParams: (v) =>
STATUS_OPTIONS.find((o) => o.value === v.v[0])?.params ?? {},
},
{
key: "type",
label: "Type",
type: "enum",
multiple: false,
options: (
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
).map((value) => ({ value, label: humanize(value) })),
},
{
key: "kind",
label: "Sector",
type: "enum",
multiple: false,
options: [
{ value: "commercial", label: "Commercial" },
{ value: "government", label: "Government" },
],
},
{
key: "nationality",
label: "Nationality",
type: "enum",
multiple: false,
options: [
{ value: "ethiopian", label: "Ethiopian" },
{ value: "foreign", label: "Foreign" },
],
},
{
key: "created",
label: "Registered",
type: "date",
secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("createdFrom", "createdTo"),
},
];
export default function CustomersPage() {
const navigate = useNavigate();
const [view, setView] = useState<CustomerView>("all");
const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 });
const controls = useFilters(CUSTOMER_FILTER_DEFS, {
defaultSort: "review:DESC",
pageSize: 10,
});
const filter = useMemo(() => {
const [sortBy, sortOrder] = controls.sort.split(":") as [
"review" | "name" | "createdAt" | "updatedAt",
"ASC" | "DESC",
];
return {
page: controls.page,
pageSize: controls.pageSize,
search: String(controls.params.search ?? ""),
sortBy,
sortOrder,
...VIEW_FILTERS[view],
};
}, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]);
// `controls.params` is the whole query: page/pageSize/search, the split
// sortBy/sortOrder, and every pill's mapped params.
const filter = controls.params as unknown as CompanyListFilter;
/**
* The export's `daterange` filters are coerced from calendar days while the
* list takes ISO instants — hand the dialog the local day each bound falls on
* so the file covers the same range the screen shows.
*/
const exportParams = useMemo(() => {
const out: Record<string, unknown> = { ...controls.params };
for (const key of ["createdFrom", "createdTo"]) {
if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string);
}
return out;
}, [controls.params]);
const { data: stats } = useQuery(
api.customers.stats.queryOptions({ input: {} }),
@@ -293,33 +346,13 @@ export default function CustomersPage() {
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FilterBar
defs={NO_FILTER_DEFS}
defs={CUSTOMER_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search by company, TIN, email or profile reference…"
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
viewId="customers"
>
<SegmentedControl
size="sm"
radius="md"
value={view}
onChange={(v) => {
// `view` lives outside useFilters (it's a tab, not a
// filter pill), so switching it needs its own page reset —
// the same "stranded on page 5" hazard useFilters guards
// against for its own filters.
setView(v as CustomerView);
controls.setPage(1);
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
{ label: "Pending changes", value: "pendingChanges" },
{ label: "Onboarding", value: "onboarding" },
{ label: "Active", value: "active" },
]}
/>
<ExportButton datasetKey="customers" params={controls.params} />
<ExportButton datasetKey="customers" params={exportParams} />
</FilterBar>
</Box>
@@ -331,8 +364,8 @@ export default function CustomersPage() {
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
emptyMessage={
controls.searchText
? "No companies match your search."
controls.activeCount > 0
? "No companies match these filters."
: "No companies yet."
}
error={

View File

@@ -3,7 +3,6 @@ import { AlertCircle } from "lucide-react";
import { Alert, Button, Skeleton, Stack } from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/auth/useAuth";
import { PageContainer } from "@/components/page";
import { ClearanceOverview } from "@/components/overview/layouts/ClearanceOverview";
import { ExecutiveOverview } from "@/components/overview/layouts/ExecutiveOverview";
@@ -20,7 +19,7 @@ import {
import { OverviewHero } from "@/components/overview/summary/OverviewHero";
import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useOverview } from "@/hooks/useOverview";
import { useOverview, useOverviewLayouts } from "@/hooks/useOverview";
import type { OverviewRange } from "@/types/overview";
import "@/components/overview/summary/overview-summary.css";
@@ -57,13 +56,14 @@ function OverviewSkeleton() {
const OverviewPage = () => {
const [range, setRange] = useState<OverviewRange>("30d");
const queryClient = useQueryClient();
const { user } = useAuth();
const { data, isLoading, isError, error, refetch, isFetching } =
useOverview(range);
const { data: layouts, isLoading: layoutsLoading } = useOverviewLayouts();
// Hero, range control and headline KPIs are role-neutral; everything below
// them is chosen by role key.
const layoutKey = resolveOverviewLayout(user);
// them is chosen by which overview:<key>:view permissions the caller holds
// (GET /overview/layouts already filtered these server-side).
const layoutKey = resolveOverviewLayout(layouts?.map((l) => l.key));
const RoleLayout = layoutKey ? LAYOUTS[layoutKey] : null;
const accessDenied =
@@ -129,7 +129,7 @@ const OverviewPage = () => {
</Alert>
)}
{isLoading && !data ? (
{(isLoading || layoutsLoading) && !data ? (
<Stack mt="lg">
<OverviewSkeleton />
</Stack>

View File

@@ -224,6 +224,21 @@ const FleetResourcePage = () => {
secondary: true,
toParams: dateRangeParams("createdFrom", "createdTo"),
};
// Wagons-only: "last maintenance" is a derived value (latest status-log
// flip to MAINTENANCE), not a column other fleet resources have.
const dateDefs: FilterDef[] =
slug === "wagons"
? [
dateDef,
{
key: "lastMaintenance",
label: "Last maintenance",
type: "date",
secondary: true,
toParams: dateRangeParams("maintenanceFrom", "maintenanceTo"),
},
]
: [dateDef];
if (config?.listFilters?.length) {
return [
...config.listFilters.map((filter): FilterDef => ({
@@ -235,13 +250,13 @@ const FleetResourcePage = () => {
? (dynamicOptions[filter.dynamicOptions] ?? [])
: (filter.options ?? []),
})),
dateDef,
...dateDefs,
];
}
const fallback = FALLBACK_STATUS_OPTIONS[slug];
return fallback
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef]
: [dateDef];
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, ...dateDefs]
: dateDefs;
}, [config, dynamicOptions, slug]);
const controls = useFilters(filterDefs, { pageSize: 10 });

View File

@@ -1,29 +1,127 @@
import type { Freight } from "@edr/types";
import {
ActionIcon,
Badge,
Box,
Card,
Group,
SegmentedControl,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { Freight } from "@edr/types";
import { ActionIcon, Badge, Box, Card, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Banknote, CircleDollarSign, Landmark, RefreshCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { Banknote, CircleDollarSign, Landmark, RefreshCw } from "lucide-react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { InvoiceStatusBadge, formatDate, formatMoney, humanize } from "@/components/customers";
import {
FilterBar,
dateRangeParams,
isoToLocalDateStr,
useFilters,
type FilterDef,
} from "@/components/filters";
import { KpiStrip } from "@/components/page";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { ExportButton } from "@/components/export/ExportButton";
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
import type { Invoice, InvoiceListFilter } from "@/types/invoice";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({
value,
label: humanize(value),
}));
const SOURCE_OPTIONS = Object.values(Freight.InvoiceSource).map((value) => ({
value,
label: humanize(value),
}));
/** Mirrors `EimsInvoiceStatus` in the API — Finance's "what still needs filing" cut. */
const EIMS_STATUS_OPTIONS = [
"NOT_SUBMITTED",
"SUBMITTING",
"REGISTERED",
"FAILED",
"UNKNOWN",
"CANCELLED",
].map((value) => ({ value, label: humanize(value) }));
/**
* Every dimension the list narrows by. Keys are the URL keys; `toParams` maps
* them onto the API's `FilterInvoiceDto`. Secondary defs sit behind "More
* filters" until they hold a value, then pin themselves as a pill.
*/
const INVOICE_FILTER_DEFS: FilterDef[] = [
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{ key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS },
{
key: "currency",
label: "Currency",
type: "enum",
multiple: false,
options: [
{ value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" },
],
},
{
// One pill for the two settlement cuts Finance actually chases. Both are
// computed from the balance and due date rather than read off `status` —
// nothing sweeps PENDING rows into OVERDUE, so the status under-reports.
key: "settlement",
label: "Settlement",
type: "enum",
multiple: false,
options: [
{ value: "outstanding", label: "Outstanding" },
{ value: "overdue", label: "Overdue" },
],
toParams: (v) =>
v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" },
},
{
key: "issued",
label: "Issued",
type: "date",
operators: ["between", "before", "after"],
toParams: dateRangeParams("issuedFrom", "issuedTo"),
},
{
key: "due",
label: "Due",
type: "date",
secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("dueFrom", "dueTo"),
},
{
key: "amount",
label: "Amount",
type: "number",
secondary: true,
operators: ["between", "is"],
// Amounts are compared in each invoice's OWN currency — pair this with the
// currency pill when the mix matters.
toParams: (v) =>
v.op === "between"
? { minAmount: v.v[0], maxAmount: v.v[1] }
: { minAmount: v.v[0], maxAmount: v.v[0] },
},
{
key: "eimsStatuses",
label: "EIMS",
type: "enum",
secondary: true,
options: EIMS_STATUS_OPTIONS,
},
];
const SORT_OPTIONS = [
{ value: "issuedAt:DESC", label: "Newest issued" },
{ value: "issuedAt:ASC", label: "Oldest issued" },
{ value: "dueAt:ASC", label: "Due soonest" },
{ value: "totalAmount:DESC", label: "Largest amount" },
{ value: "balanceAmount:DESC", label: "Largest balance" },
{ value: "invoiceNumber:ASC", label: "Invoice no. (AZ)" },
];
/** Date params the export's `daterange` coercion expects as calendar days. */
const EXPORT_DAY_KEYS = ["issuedFrom", "issuedTo", "dueFrom", "dueTo"];
/**
* Which record raised the invoice, not just which subsystem. The source label
@@ -65,20 +163,12 @@ function InvoiceSourceCell({ invoice }: { invoice: Invoice }) {
/** Invoices tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function InvoicesPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>("");
const controls = useFilters(INVOICE_FILTER_DEFS, {
defaultSort: "issuedAt:DESC",
pageSize: 10,
});
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
);
const filter = controls.params as unknown as InvoiceListFilter;
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.invoices.list.queryOptions({ input: { filter } }),
@@ -86,7 +176,6 @@ export default function InvoicesPanel() {
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
// Shipping-line credit invoices carry makerchecker actions (mark paid /
// cancel). One batched lookup fetches the visible rows' pending requests.
@@ -106,14 +195,28 @@ export default function InvoicesPanel() {
);
// Summary card: total collected (paidAmount) across every invoice matching
// the current search/status filters, not just the visible page.
// the current filters, not just the visible page. Same params minus
// pagination, so the card can never total a different set than the table.
const summaryFilter = useMemo(() => {
const { page: _page, pageSize: _pageSize, ...rest } = filter;
return rest;
}, [filter]);
const { data: summary, isLoading: summaryLoading } = useQuery(
api.invoices.collectedSummary.queryOptions({
input: {
filter: { search: debouncedQuery, status: statusFilter || undefined },
},
}),
api.invoices.collectedSummary.queryOptions({ input: { filter: summaryFilter } }),
);
/**
* The export's `daterange` filters are coerced from calendar days, while the
* list takes ISO instants — hand the dialog the local day each bound falls
* on so an exported file covers the same range the screen shows.
*/
const exportParams = useMemo(() => {
const out: Record<string, unknown> = { ...controls.params };
for (const key of EXPORT_DAY_KEYS) {
if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string);
}
return out;
}, [controls.params]);
const { data: exchangeSettings } = useExchangeSettingsQuery();
const etbCollected = summary?.ETB ?? 0;
const usdCollected = summary?.USD ?? 0;
@@ -236,45 +339,14 @@ export default function InvoicesPanel() {
<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 invoice, customer, booking ref, GRN or shipping line…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<ExportButton datasetKey="invoices" params={filter} size="sm" />
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
setStatusFilter(v === "all" ? "" : (v as Freight.InvoiceStatus));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending", value: "PENDING" },
{ label: "Payment processing", value: "PAYMENT_PROCESSING" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<FilterBar
defs={INVOICE_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search invoice, customer, booking ref, GRN or shipping line…"
sortOptions={SORT_OPTIONS}
viewId="invoices"
>
<ExportButton datasetKey="invoices" params={exportParams} size="sm" />
<ActionIcon
variant="default"
size="lg"
@@ -285,7 +357,7 @@ export default function InvoicesPanel() {
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</FilterBar>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
@@ -296,7 +368,9 @@ export default function InvoicesPanel() {
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery ? "No invoices match your search." : "No invoices yet."
controls.activeCount > 0
? "No invoices match these filters."
: "No invoices yet."
}
error={
isError
@@ -306,18 +380,7 @@ export default function InvoicesPanel() {
}
: undefined
}
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"
footer={DataTableFooter}
/>

View File

@@ -151,6 +151,12 @@ const TRADE_DIRECTIONS = [
* a report matches a target by this exact key, so a value here that the API
* does not emit is a plan the report will never find. The API spec
* `operations-classification.spec.ts` guards the API side of the pair.
*
* Drift is no longer silent: `OperationsTargetsService.assertDimensionKey`
* rejects any key outside the API's own vocabulary, so a stale entry here
* surfaces as a 400 on save rather than a plan that quietly never joins.
* `UNCLASSIFIED` is left out deliberately — the API accepts it, but there is no
* sense in planning against cargo nobody has classified.
*/
export const OPERATIONS_CARGO_CATEGORIES = [
{ label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" },
@@ -713,14 +719,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
},
{
// Mirrors TARGET_PERIOD_LABELS in the API's operations-target entity.
// Commit the number at whatever grain the business quotes it — the
// report re-gathers it into whichever grain the viewer asks for.
name: "periodType",
label: "Period",
type: "select",
required: true,
options: [
{ label: "Daily", value: "day" },
{ label: "Weekly", value: "week" },
{ label: "Monthly", value: "month" },
{ label: "Quarterly", value: "quarter" },
{ label: "Half-yearly", value: "half_year" },
{ label: "Nine-monthly", value: "nine_month" },
{ label: "90-day", value: "ninety_day" },
{ label: "Yearly", value: "year" },
],
},

View File

@@ -17,6 +17,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
@@ -93,6 +95,9 @@ const apiErrorMessage = (error: unknown) => {
function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
const queryClient = useQueryClient();
const refresh = () =>
queryClient.invalidateQueries({
@@ -201,31 +206,37 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Group gap="xs" justify="flex-end" wrap="nowrap">
{/* Work the cargo right here while the train is at the yard. */}
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
onClick={() =>
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Load
</Button>
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad}>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
disabled={!canLoad}
onClick={() =>
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Load
</Button>
</Tooltip>
)}
{r.trainScheduleId && atDestination(r) && isRiding(r) && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
onClick={() =>
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Unload
</Button>
<Tooltip label={canUnload ? undefined : "You don't have permission to unload cargo"} disabled={canUnload}>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
disabled={!canUnload}
onClick={() =>
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Unload
</Button>
</Tooltip>
)}
</Group>
</Table.Td>

View File

@@ -1,9 +1,10 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Card, Center, Divider, Group, Loader, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { Button, Card, Center, Group, Loader, Popover, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import {
ClipboardList,
Filter,
PackageCheck,
PackageOpen,
PackagePlus,
@@ -17,7 +18,6 @@ import {
} from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import { getDateRangePresets } from '@/components/common/dateRangePresets';
import {
AccrualDashboard,
CycleTimeCard,
@@ -44,44 +44,42 @@ interface Metric {
icon: React.ReactNode;
/** Route to navigate to when the card is clicked. */
to: string;
theme: string;
}
const ORANGE = 'rgb(241, 147, 23)';
const GREEN = '#084b21';
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN },
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={22} />, to: '/dashboard/export-warehouse', theme: ORANGE },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: GREEN },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: ORANGE },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: GREEN },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: ORANGE },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={18} />, to: '/dashboard/warehouses' },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={18} />, to: '/dashboard/warehouse-inventory' },
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={18} />, to: '/dashboard/containers' },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={18} />, to: '/dashboard/import-warehouse' },
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={18} />, to: '/dashboard/export-warehouse' },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={18} />, to: '/dashboard/loaded-inventory' },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={18} />, to: '/dashboard/dispatch-queue' },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={18} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP' },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={18} />, to: '/dashboard/loading-queue' },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={18} />, to: '/dashboard/warehouse-inventory?status=DELIVERED' },
];
export default function WarehouseDashboardPage() {
const navigate = useNavigate();
// Both null → the API defaults `received` to "today", matching the page's original behaviour.
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]);
// null → the API defaults `received` to "today", matching the page's original behaviour.
const [receivedDate, setReceivedDate] = useState<string | null>(null);
const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [dateFrom, dateTo] = dateRange;
const hasCustomRange = Boolean(dateFrom || dateTo);
const [filtersOpen, setFiltersOpen] = useState(false);
const hasCustomDate = Boolean(receivedDate);
const warehousesQuery = useWarehouses();
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const activeFilterCount = (warehouseId ? 1 : 0) + (hasCustomDate ? 1 : 0);
const { data, isError, isLoading } = useWarehouseDashboard({
dateFrom: dateFrom ?? undefined,
dateTo: dateTo ?? undefined,
// Same date both ends → the one day the picker selected, inclusive.
dateFrom: receivedDate ?? undefined,
dateTo: receivedDate ?? undefined,
warehouseId: warehouseId ?? undefined,
});
@@ -89,57 +87,64 @@ export default function WarehouseDashboardPage() {
<PageContainer>
<PageHeader
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
subtitle="Freight import/export logistics operations overview"
action={
<Group gap="sm" wrap="wrap" justify="flex-end">
<Select
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={warehouseId}
onChange={setWarehouseId}
w={220}
/>
<DatePickerInput
type="range"
placeholder="Received: today"
value={dateRange}
onChange={setDateRange}
presets={getDateRangePresets()}
value={receivedDate}
onChange={setReceivedDate}
clearable
w={230}
w={180}
/>
<Badge
color="edr-green"
variant="light"
size="lg"
leftSection={
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--mantine-color-edr-green-6)',
}}
/>
}
>
Live · updates every 60s
</Badge>
<Popover opened={filtersOpen} onChange={setFiltersOpen} position="bottom-end" withArrow shadow="md">
<Popover.Target>
<Button
variant="default"
leftSection={<Filter size={16} />}
rightSection={activeFilterCount > 0 ? <Text size="xs" fw={700} c="edr-green">{activeFilterCount}</Text> : null}
onClick={() => setFiltersOpen((o) => !o)}
>
Filters
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm" w={240}>
<Select
label="Warehouse"
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={warehouseId}
onChange={setWarehouseId}
/>
{activeFilterCount > 0 && (
<Button
variant="subtle"
color="gray"
size="xs"
onClick={() => {
setWarehouseId(null);
setReceivedDate(null);
}}
>
Clear filters
</Button>
)}
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
}
/>
{(warehouseId || hasCustomRange) && (
{(warehouseId || hasCustomDate) && (
<Text size="xs" c="dimmed" mt={-8}>
Scoped to{' '}
{warehouseId ? warehouseOptions.find((o) => o.value === warehouseId)?.label ?? 'selected warehouse' : 'all warehouses'}
{hasCustomRange
? ` · Received counts ${dateFrom ?? '…'} to ${dateTo ?? '…'}`
: ' · Received counts: today'}
. Status-backlog and fleet counters are always current regardless of the date range.
{hasCustomDate ? ` · Received counts for ${receivedDate}` : ' · Received counts: today'}
. Status-backlog and fleet counters are always current regardless of the date filter.
</Text>
)}
@@ -152,41 +157,33 @@ export default function WarehouseDashboardPage() {
<Text c="red">Failed to load warehouse dashboard.</Text>
</Center>
) : (
<Stack gap="xl">
<Stack gap="lg">
{/* Needs attention — live ops counters (received today, pending
inspection, trucks on-site, items aging > 7 days). */}
<Stack gap="sm">
<SectionTitle>Needs attention</SectionTitle>
<WarehouseOpsKpiStrip />
</Stack>
<Divider />
<WarehouseOpsKpiStrip />
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card
key={metric.key}
padding="lg"
padding="md"
withBorder
radius="md"
onClick={() => navigate(metric.to)}
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.key === 'received' && hasCustomRange ? 'Received' : metric.label}
</Text>
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon
variant="light"
size={46}
radius="md"
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon color="edr-green" variant="light" size={40} radius="md">
{metric.icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="edr-muted" fw={600}>
{metric.key === 'received' && hasCustomDate ? 'Received' : metric.label}
</Text>
<Text fw={700} fz={20} c="edr-text" lh={1.2}>
{data ? data[metric.key] : 0}
</Text>
</Stack>
</Group>
</Card>
))}

View File

@@ -583,6 +583,8 @@ export const bookingsService = {
currency: string;
action: "draft" | "send";
file?: File | null;
/** ISO date (YYYY-MM-DD); omit to fall back to the invoice's default 14-day term. */
dueDate?: string | null;
},
): Promise<Freight.AdditionalCharge[]> => {
const form = new FormData();
@@ -590,6 +592,7 @@ export const bookingsService = {
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
form.append("action", payload.action);
if (payload.dueDate) form.append("dueDate", payload.dueDate);
if (payload.file) form.append("file", payload.file);
const response = await client.post(
`/bookings/${id}/additional-charges`,

View File

@@ -1,6 +1,7 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { OverviewLayoutKey } from "@/components/overview/role-dashboards.config";
import type {
IOverviewBillingTab,
IOverviewBookingsTab,
@@ -16,7 +17,19 @@ import type {
const O = URL_CONSTANTS.OVERVIEW;
/** Mirrors the API's OverviewLayoutDto — one entry per GET /overview/layouts item. */
export interface IOverviewLayoutOption {
key: OverviewLayoutKey;
label: string;
}
export const overviewService = {
/** Layouts the caller has permission to render, in server priority order. */
getLayouts: async (): Promise<IOverviewLayoutOption[]> => {
const response = await client.get<IOverviewLayoutOption[]>(O.LAYOUTS);
return unwrap(response);
},
getDashboard: async (range?: OverviewRange): Promise<IOverviewDashboard> => {
const response = await client.get<IOverviewDashboard>(O.BASE, {
params: range ? { range } : undefined,

View File

@@ -51,6 +51,10 @@ export interface WagonListFilters {
/** Registration day range (YYYY-MM-DD), both ends inclusive. */
createdFrom?: string;
createdTo?: string;
/** Last-maintenance day range (YYYY-MM-DD), both ends inclusive — matches
* the latest status-log flip to MAINTENANCE, not a stored column. */
maintenanceFrom?: string;
maintenanceTo?: string;
/** Only read by `getPaged`. */
page?: number;
pageSize?: number;
@@ -67,6 +71,8 @@ const wagonListQuery = (filters: WagonListFilters): string => {
if (filters.trainNumber) params.set('trainNumber', filters.trainNumber);
if (filters.createdFrom) params.set('createdFrom', filters.createdFrom);
if (filters.createdTo) params.set('createdTo', filters.createdTo);
if (filters.maintenanceFrom) params.set('maintenanceFrom', filters.maintenanceFrom);
if (filters.maintenanceTo) params.set('maintenanceTo', filters.maintenanceTo);
if (filters.page) params.set('page', String(filters.page));
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
const qs = params.toString();

View File

@@ -313,6 +313,10 @@ export interface CompanyListFilter {
type?: CompanyType;
kind?: CompanyKind;
status?: CompanyStatus;
nationality?: CompanyNationality;
/** ISO instants — inclusive bounds on the registration date. */
createdFrom?: string;
createdTo?: string;
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
onboardingCompleted?: boolean;
/**

View File

@@ -24,15 +24,39 @@ export interface Invoice extends Freight.IInvoice {
sourceRef?: InvoiceSourceRef | null;
}
/** Query parameters for the invoice list. */
/**
* Query parameters for the invoice list. Every key maps 1:1 onto
* `FilterInvoiceDto` on the API — the list endpoint runs with
* `forbidNonWhitelisted`, so a param that isn't declared there is a 400, not a
* silently ignored extra.
*/
export interface InvoiceListFilter {
page: number;
pageSize: number;
companyId?: string;
/** Single status — kept for the worklists that pin one. */
status?: Freight.InvoiceStatus;
/** CSV multi-select status, as the filter bar sends it. */
statuses?: string;
/** CSV of `Freight.InvoiceSource` values. */
sources?: string;
/** CSV of EIMS filing states. */
eimsStatuses?: string;
search?: string;
/** Manual-payments worklist only. */
currency?: "USD" | "ETB";
/** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */
issuedFrom?: string;
issuedTo?: string;
dueFrom?: string;
dueTo?: string;
minAmount?: number;
maxAmount?: number;
/** Outstanding balance only. */
hasBalance?: boolean;
/** Outstanding AND past due — computed, not read off `status`. */
overdue?: boolean;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
/** Standard paginated list envelope (matches the customers/bookings service shape). */