mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
fix issue
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Ban,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Plus,
|
||||
Receipt,
|
||||
Send,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
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 { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const CURRENCIES = ["ETB", "USD"];
|
||||
|
||||
const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
|
||||
DRAFT: { label: "Draft", color: "gray" },
|
||||
SENT: { label: "Sent — unpaid", color: "orange" },
|
||||
PAID: { label: "Paid", color: "edr-green" },
|
||||
CANCELLED: { label: "Cancelled", color: "red" },
|
||||
};
|
||||
|
||||
export interface AdditionalPaymentsTabProps {
|
||||
bookingId: string;
|
||||
onViewFile: (file: { name: string; url: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ad-hoc extra charges finance raises against a booking — any number, free-text
|
||||
* reason. Draft until sent; sending issues the payable invoice and notifies the
|
||||
* customer (in-app + SMS + email). Settles the same way every invoice does.
|
||||
*/
|
||||
export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPaymentsTabProps) {
|
||||
const qc = useQueryClient();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const { data: charges, isLoading } = useQuery({
|
||||
queryKey: ["additional-charges", bookingId],
|
||||
queryFn: () => bookingsService.getAdditionalCharges(bookingId),
|
||||
});
|
||||
|
||||
const refresh = (next: Freight.AdditionalCharge[]) =>
|
||||
qc.setQueryData(["additional-charges", bookingId], next);
|
||||
const onError = (e: unknown) =>
|
||||
toast.error(extractErrorMessage(e, "Could not update the charge"));
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (p: {
|
||||
reason: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
action: "draft" | "send";
|
||||
file?: File | null;
|
||||
}) => bookingsService.createAdditionalCharge(bookingId, p),
|
||||
onSuccess: (next, p) => {
|
||||
toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved");
|
||||
refresh(next);
|
||||
setModalOpen(false);
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const send = useMutation({
|
||||
mutationFn: (chargeId: string) => bookingsService.sendAdditionalCharge(bookingId, chargeId),
|
||||
onSuccess: (next) => {
|
||||
toast.success("Charge sent to the customer");
|
||||
refresh(next);
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const cancel = useMutation({
|
||||
mutationFn: (chargeId: string) => bookingsService.cancelAdditionalCharge(bookingId, chargeId),
|
||||
onSuccess: (next) => {
|
||||
toast.success("Charge cancelled");
|
||||
refresh(next);
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="xl" gap={10}>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
<Text c="dimmed">Loading additional charges…</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = charges ?? [];
|
||||
const busy = send.isPending || cancel.isPending;
|
||||
|
||||
return (
|
||||
<Stack gap="md" maw={860}>
|
||||
<Group justify="space-between">
|
||||
<Text fz="13px" fw={700} c="edr-text">
|
||||
Additional charges
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
Add charge
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{rows.length === 0 && (
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
No additional charges raised on this booking yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{rows.map((charge) => (
|
||||
<ChargeCard
|
||||
key={charge.id}
|
||||
charge={charge}
|
||||
busy={busy}
|
||||
onViewFile={onViewFile}
|
||||
onSend={() => send.mutate(charge.id)}
|
||||
onCancel={() => cancel.mutate(charge.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<AddChargeModal
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
busy={create.isPending}
|
||||
onSubmit={(p) => create.mutate(p)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ChargeCard({
|
||||
charge,
|
||||
busy,
|
||||
onViewFile,
|
||||
onSend,
|
||||
onCancel,
|
||||
}: {
|
||||
charge: Freight.AdditionalCharge;
|
||||
busy: boolean;
|
||||
onViewFile: (file: { name: string; url: string }) => void;
|
||||
onSend: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const meta = STATUS_META[charge.status];
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap={10} wrap="nowrap" align="flex-start">
|
||||
<Receipt size={18} color="var(--mantine-color-edr-green-6)" />
|
||||
<Box>
|
||||
<Text fz="14px" fw={700} c="edr-text">
|
||||
{charge.reason}
|
||||
</Text>
|
||||
<Text fz="11.5px" c="dimmed">
|
||||
Raised{charge.createdByName ? ` by ${charge.createdByName}` : ""} ·{" "}
|
||||
{formatDateTime(charge.createdAt)}
|
||||
</Text>
|
||||
{charge.sentAt && (
|
||||
<Text fz="11.5px" c="dimmed">
|
||||
Sent{charge.sentByName ? ` by ${charge.sentByName}` : ""} ·{" "}
|
||||
{formatDateTime(charge.sentAt)}
|
||||
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{charge.paidAt && (
|
||||
<Text fz="11.5px" c="edr-green.8" fw={600}>
|
||||
Paid · {formatDateTime(charge.paidAt)}
|
||||
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{charge.cancelledAt && (
|
||||
<Text fz="11.5px" c="red.7">
|
||||
Cancelled · {formatDateTime(charge.cancelledAt)}
|
||||
{charge.cancelReason ? ` — ${charge.cancelReason}` : ""}
|
||||
</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>
|
||||
</Group>
|
||||
|
||||
{charge.file && (
|
||||
<Group gap={8} mt="sm" wrap="nowrap">
|
||||
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
|
||||
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0 }}>
|
||||
{charge.file.name}
|
||||
</Text>
|
||||
{isViewable({ name: charge.file.name, url: "" }) && (
|
||||
<Tooltip label="View">
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void fetchViewableFile(charge.file!.id, charge.file!.name).then(onViewFile)
|
||||
}
|
||||
c="edr-green"
|
||||
style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }}
|
||||
>
|
||||
<Eye size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => void downloadBookingFile(charge.file!.id, charge.file!.name)}
|
||||
c="edr-green"
|
||||
style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{(charge.status === "DRAFT" || charge.status === "SENT") && (
|
||||
<Group mt="sm" gap={8} justify="flex-end">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<Ban size={14} />}
|
||||
disabled={busy}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{charge.status === "DRAFT" && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={14} />}
|
||||
disabled={busy}
|
||||
onClick={onSend}
|
||||
>
|
||||
Send to customer
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function AddChargeModal({
|
||||
opened,
|
||||
onClose,
|
||||
busy,
|
||||
onSubmit,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
busy: boolean;
|
||||
onSubmit: (p: {
|
||||
reason: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
action: "draft" | "send";
|
||||
file?: File | 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 valid = reason.trim().length > 0 && Number(amount) > 0;
|
||||
|
||||
const reset = () => {
|
||||
setReason("");
|
||||
setAmount("");
|
||||
setCurrency("ETB");
|
||||
setFile(null);
|
||||
};
|
||||
|
||||
const submit = (action: "draft" | "send") => {
|
||||
if (!valid) return;
|
||||
onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
reset();
|
||||
}}
|
||||
title="Add additional charge"
|
||||
radius="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Textarea
|
||||
label="Reason for charge"
|
||||
placeholder="e.g. Re-weighing fee at Mojo dry port"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Group gap={8} align="flex-end">
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
min={0.01}
|
||||
decimalScale={2}
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={CURRENCIES}
|
||||
value={currency}
|
||||
onChange={(v) => v && setCurrency(v)}
|
||||
w={100}
|
||||
/>
|
||||
</Group>
|
||||
<FileButton onChange={setFile} accept="application/pdf,image/*">
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={14} />}
|
||||
>
|
||||
{file ? file.name : "Attach a document (optional)"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<Group justify="flex-end" mt="sm" gap={8}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="md"
|
||||
disabled={busy || !valid}
|
||||
loading={busy}
|
||||
onClick={() => submit("draft")}
|
||||
>
|
||||
Save draft
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={14} />}
|
||||
disabled={busy || !valid}
|
||||
loading={busy}
|
||||
onClick={() => submit("send")}
|
||||
>
|
||||
Send to customer
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ExternalLink, MoreHorizontal } from "lucide-react";
|
||||
import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react";
|
||||
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
||||
|
||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||
import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||
import {
|
||||
isAllocateAction,
|
||||
isClearanceNavAction,
|
||||
@@ -50,6 +51,14 @@ export function BookingActionsMenu({
|
||||
const goToClearanceTab = () =>
|
||||
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
|
||||
|
||||
const goToAdditionalCharges = () =>
|
||||
navigate(`/dashboard/booking-requests/${row.id}?tab=additional-charges`);
|
||||
|
||||
const canSeeAdditionalCharges = hasFreightPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.additionalCharges.view,
|
||||
);
|
||||
|
||||
const handleAction = (action: (typeof actions)[number]) => {
|
||||
onSuppressRowClick?.();
|
||||
if (isContractNavAction(action.id)) {
|
||||
@@ -144,6 +153,17 @@ export function BookingActionsMenu({
|
||||
);
|
||||
})}
|
||||
{actions.length > 0 && <Menu.Divider />}
|
||||
{canSeeAdditionalCharges && (
|
||||
<Menu.Item
|
||||
leftSection={<Receipt size={15} />}
|
||||
onClick={() => {
|
||||
onSuppressRowClick?.();
|
||||
goToAdditionalCharges();
|
||||
}}
|
||||
>
|
||||
Additional charges
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<ExternalLink size={15} />}
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Tooltip } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { ExportParams } from "@/types/exports";
|
||||
|
||||
import { ExportDialog } from "./ExportDialog";
|
||||
|
||||
/**
|
||||
* Pagination is a screen concern, never an export one — stripped here, once,
|
||||
* rather than at each of the pages that mount this.
|
||||
*/
|
||||
const PAGINATION_KEYS = ["page", "pageSize", "skip", "take"];
|
||||
|
||||
export interface ExportButtonProps {
|
||||
/** Catalog dataset key, e.g. "bookings". */
|
||||
datasetKey: string;
|
||||
/**
|
||||
* The page's current filters — `useFilters().params` verbatim, or a
|
||||
* non-migrated page's hand-built filter object. Deliberately not typed as
|
||||
* `UseFilters`: four of the pages that need this haven't migrated yet.
|
||||
*/
|
||||
params?: Record<string, unknown>;
|
||||
label?: string;
|
||||
size?: "xs" | "sm";
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the export dialog for one dataset. Renders nothing when the caller
|
||||
* lacks permission for that dataset — the catalog only returns what they may
|
||||
* export, so an absent entry IS the permission check.
|
||||
*/
|
||||
export function ExportButton({
|
||||
datasetKey,
|
||||
params,
|
||||
label = "Export",
|
||||
size = "xs",
|
||||
}: ExportButtonProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { data: catalog, isLoading } = useQuery(
|
||||
api.exports.catalog.queryOptions({ staleTime: 5 * 60_000 }),
|
||||
);
|
||||
|
||||
const dataset = catalog?.find((d) => d.key === datasetKey);
|
||||
|
||||
const exportParams = useMemo<ExportParams>(() => {
|
||||
const out: ExportParams = {};
|
||||
for (const [key, value] of Object.entries(params ?? {})) {
|
||||
if (PAGINATION_KEYS.includes(key)) continue;
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
out[key] = value as string | number;
|
||||
}
|
||||
return out;
|
||||
}, [params]);
|
||||
|
||||
if (isLoading || !dataset) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={dataset.description} openDelay={500}>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size={size}
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{opened && (
|
||||
<ExportDialog
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
dataset={dataset}
|
||||
params={exportParams}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExportButton;
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Popover,
|
||||
Radio,
|
||||
ScrollArea,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Download, FileSpreadsheet, FileText, Search, Table, TriangleAlert, X } from "lucide-react";
|
||||
|
||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||
import { saveBlob } from "@/components/warehouses/pdf";
|
||||
import { useSavedViews } from "@/components/filters";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import { exportsService } from "@/services/exports.service";
|
||||
import type {
|
||||
ExportDatasetEntry,
|
||||
ExportFormat,
|
||||
ExportParams,
|
||||
} from "@/types/exports";
|
||||
|
||||
const FORMAT_META: Record<ExportFormat, { label: string; Icon: typeof FileText; hint: string }> = {
|
||||
csv: { label: "CSV", Icon: Table, hint: "Best for many columns" },
|
||||
xlsx: { label: "Excel", Icon: FileSpreadsheet, hint: "Typed number columns" },
|
||||
pdf: { label: "PDF", Icon: FileText, hint: "Few columns only" },
|
||||
};
|
||||
|
||||
const ROW_SCOPES = [
|
||||
{ value: "all", label: "All matching filters" },
|
||||
{ value: "100", label: "First 100" },
|
||||
{ value: "1000", label: "First 1,000" },
|
||||
{ value: "5000", label: "First 5,000" },
|
||||
];
|
||||
|
||||
/** Beyond this a PDF's columns are too narrow to read; we warn, the server allows it. */
|
||||
const PDF_FIELD_WARN = 12;
|
||||
|
||||
export interface ExportDialogProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
dataset: ExportDatasetEntry;
|
||||
/** The page's current filters. Pagination keys are stripped by ExportButton. */
|
||||
params: ExportParams;
|
||||
}
|
||||
|
||||
export function ExportDialog({ opened, onClose, dataset, params }: ExportDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const defaultKeys = useMemo(
|
||||
() => dataset.fields.filter((f) => f.default).map((f) => f.key),
|
||||
[dataset.fields],
|
||||
);
|
||||
|
||||
const [selected, setSelected] = useState<string[]>(defaultKeys);
|
||||
// xlsx by default: typed number and date columns, so a spreadsheet opens it
|
||||
// without the "is this text?" pass CSV needs. Falls back to whatever the
|
||||
// dataset does offer rather than presetting a format it would reject.
|
||||
const [format, setFormat] = useState<ExportFormat>(
|
||||
() => (dataset.formats.includes("xlsx") ? "xlsx" : dataset.formats[0]),
|
||||
);
|
||||
const [scope, setScope] = useState("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [presetName, setPresetName] = useState("");
|
||||
const [savePresetOpen, setSavePresetOpen] = useState(false);
|
||||
|
||||
// A preset is stored as a query string so the existing saved-views hook can
|
||||
// hold it unchanged — see useExportPresets note below.
|
||||
const presets = useSavedViews(`export:${dataset.key}`);
|
||||
|
||||
const { data: countData, isLoading: countLoading } = useQuery({
|
||||
...api.exports.count.queryOptions({ input: { key: dataset.key, params } }),
|
||||
enabled: opened,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const total = countData?.total;
|
||||
const cap = dataset.caps[format];
|
||||
const limit = scope === "all" ? undefined : Number(scope);
|
||||
const rowsToExport = total === undefined ? undefined : Math.min(total, limit ?? total);
|
||||
const overCap = total !== undefined && limit === undefined && total > cap;
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
const fieldKeys = useMemo(() => new Set(dataset.fields.map((f) => f.key)), [dataset.fields]);
|
||||
|
||||
const visibleByGroup = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const out = new Map<string, typeof dataset.fields>();
|
||||
for (const group of dataset.groups) {
|
||||
const fields = dataset.fields.filter(
|
||||
(f) => f.group === group.id && (!q || f.label.toLowerCase().includes(q)),
|
||||
);
|
||||
if (fields.length) out.set(group.id, fields);
|
||||
}
|
||||
return out;
|
||||
}, [dataset.fields, dataset.groups, search]);
|
||||
|
||||
// Which groups are expanded. Real state, NOT derived from the selection:
|
||||
// deriving it made the accordion fully controlled with no way to change it,
|
||||
// so clicking a group that had nothing selected re-collapsed on the next
|
||||
// render and the group could only be opened by selecting a field in it.
|
||||
// Seeded from `default` (not the live selection) so clearing every field
|
||||
// doesn't slam the open groups shut underneath the user.
|
||||
const [expanded, setExpanded] = useState<string[]>(() =>
|
||||
dataset.groups
|
||||
.filter((g) => dataset.fields.some((f) => f.group === g.id && f.default))
|
||||
.map((g) => g.id),
|
||||
);
|
||||
|
||||
// Searching force-opens every group holding a match, so a hit can't hide
|
||||
// inside a collapsed section. It only overrides what is displayed — the
|
||||
// user's own expand state is untouched and returns when the search clears.
|
||||
const openGroups = search.trim() ? [...visibleByGroup.keys()] : expanded;
|
||||
|
||||
const toggleField = (key: string) =>
|
||||
setSelected((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
||||
|
||||
const toggleGroup = (groupId: string) => {
|
||||
const keys = dataset.fields.filter((f) => f.group === groupId).map((f) => f.key);
|
||||
const allOn = keys.every((k) => selectedSet.has(k));
|
||||
setSelected((prev) =>
|
||||
allOn ? prev.filter((k) => !keys.includes(k)) : [...new Set([...prev, ...keys])],
|
||||
);
|
||||
};
|
||||
|
||||
const applyPreset = (query: string) => {
|
||||
const p = new URLSearchParams(query);
|
||||
// Drop any key the catalog no longer offers — a stale preset must not 400
|
||||
// the download by asking for a field that has since been removed.
|
||||
const keys = (p.get("fields") ?? "").split(",").filter((k) => fieldKeys.has(k));
|
||||
if (keys.length) setSelected(keys);
|
||||
const f = p.get("format") as ExportFormat | null;
|
||||
if (f && dataset.formats.includes(f)) setFormat(f);
|
||||
};
|
||||
|
||||
const savePreset = () => {
|
||||
const name = presetName.trim();
|
||||
if (!name) return;
|
||||
presets.save(
|
||||
new URLSearchParams({ name, format, fields: selected.join(",") }).toString(),
|
||||
);
|
||||
setPresetName("");
|
||||
setSavePresetOpen(false);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await exportsService.download(dataset.key, format, selected, {
|
||||
...params,
|
||||
...(limit ? { limit } : {}),
|
||||
});
|
||||
saveBlob(blob, `${dataset.key}-${new Date().toISOString().slice(0, 10)}.${format}`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
// Blob error bodies need the async decoder, or the server's row-cap
|
||||
// message degrades to "Request failed with status code 400".
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Export failed",
|
||||
description: await extractDownloadErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={`Export ${dataset.title.toLowerCase()}`}
|
||||
size="62rem"
|
||||
radius="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Presets */}
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Chip size="xs" checked={false} onClick={() => setSelected(defaultKeys)}>
|
||||
Default columns
|
||||
</Chip>
|
||||
<Chip
|
||||
size="xs"
|
||||
checked={false}
|
||||
onClick={() => setSelected(dataset.fields.map((f) => f.key))}
|
||||
>
|
||||
All columns
|
||||
</Chip>
|
||||
{presets.views.map((view) => {
|
||||
const name = new URLSearchParams(view.query).get("name") ?? "Preset";
|
||||
return (
|
||||
<Chip
|
||||
key={view.id}
|
||||
size="xs"
|
||||
checked={false}
|
||||
onClick={() => applyPreset(view.query)}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{name}
|
||||
<X
|
||||
size={12}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
presets.remove(view.id);
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
<Popover opened={savePresetOpen} onChange={setSavePresetOpen} position="bottom-start">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
disabled={!selected.length}
|
||||
onClick={() => setSavePresetOpen((o) => !o)}
|
||||
>
|
||||
Save preset
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Preset name"
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && savePreset()}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="compact-xs" onClick={savePreset} disabled={!presetName.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Pick the data on the left, configure the file on the right. Stacks
|
||||
on a phone, where neither column has room to sit beside the other. */}
|
||||
<div className="flex flex-col gap-6 sm:flex-row">
|
||||
{/* Fields */}
|
||||
<div className="min-w-0 flex-[7]">
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Search fields…"
|
||||
leftSection={<Search size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{selected.length} of {dataset.fields.length} fields selected
|
||||
</Text>
|
||||
<Button variant="subtle" size="compact-xs" onClick={() => setSelected(defaultKeys)}>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ScrollArea.Autosize mah={420} type="auto">
|
||||
<Accordion
|
||||
multiple
|
||||
value={openGroups}
|
||||
onChange={setExpanded}
|
||||
chevronPosition="left"
|
||||
variant="contained"
|
||||
>
|
||||
{dataset.groups.map((group) => {
|
||||
const fields = visibleByGroup.get(group.id);
|
||||
if (!fields) return null;
|
||||
const groupKeys = dataset.fields
|
||||
.filter((f) => f.group === group.id)
|
||||
.map((f) => f.key);
|
||||
const on = groupKeys.filter((k) => selectedSet.has(k)).length;
|
||||
return (
|
||||
<Accordion.Item key={group.id} value={group.id}>
|
||||
<Accordion.Control>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={on === groupKeys.length}
|
||||
indeterminate={on > 0 && on < groupKeys.length}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleGroup(group.id);
|
||||
}}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
<Text size="sm" fw={500}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={on ? "edr-green" : "gray"}>
|
||||
{on}/{groupKeys.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap={2}>
|
||||
{fields.map((field) => (
|
||||
<Checkbox
|
||||
key={field.key}
|
||||
size="xs"
|
||||
label={field.label}
|
||||
checked={selectedSet.has(field.key)}
|
||||
onChange={() => toggleField(field.key)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="min-w-0 flex-[5]">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Format
|
||||
</Text>
|
||||
<Radio.Group value={format} onChange={(v) => setFormat(v as ExportFormat)}>
|
||||
<SimpleGrid cols={dataset.formats.length} spacing="xs">
|
||||
{dataset.formats.map((f) => {
|
||||
const { label, Icon } = FORMAT_META[f];
|
||||
return (
|
||||
<Radio.Card key={f} value={f} radius="md" p="xs">
|
||||
<Stack gap={4} align="center">
|
||||
{/* Radio.Card's own checked state is a border tint
|
||||
and nothing else, which reads as unselected at
|
||||
this size. The Indicator is what actually says
|
||||
which format is picked, as the report export
|
||||
dialog's cards already do. */}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Radio.Indicator size="xs" />
|
||||
<Icon size={18} />
|
||||
</Group>
|
||||
<Text size="xs" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Radio.Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Rows"
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={scope}
|
||||
onChange={(v) => setScope(v ?? "all")}
|
||||
data={ROW_SCOPES}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
Matching rows
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{countLoading ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<Text size="lg" fw={600}>
|
||||
{total?.toLocaleString() ?? "—"}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{overCap && (
|
||||
<Alert color="red" icon={<TriangleAlert size={16} />} p="xs">
|
||||
<Text size="xs">
|
||||
{total?.toLocaleString()} rows exceeds the {cap.toLocaleString()}-row{" "}
|
||||
{FORMAT_META[format].label} limit. Narrow the filters
|
||||
{dataset.caps.csv > cap ? ", switch to CSV," : ""} or{" "}
|
||||
<Anchor size="xs" onClick={() => setScope(String(cap))}>
|
||||
export the first {cap.toLocaleString()}
|
||||
</Anchor>
|
||||
.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{format === "pdf" && selected.length > PDF_FIELD_WARN && (
|
||||
<Alert color="yellow" icon={<TriangleAlert size={16} />} p="xs">
|
||||
<Text size="xs">
|
||||
{selected.length} columns is more than a PDF can show legibly. CSV or Excel
|
||||
keeps them readable.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Uses the filters currently applied on this page.
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
radius="md"
|
||||
loading={exporting}
|
||||
disabled={!selected.length || overCap}
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
{rowsToExport === undefined
|
||||
? "Export"
|
||||
: `Export ${rowsToExport.toLocaleString()} ${rowsToExport === 1 ? "row" : "rows"}`}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExportDialog;
|
||||
@@ -21,8 +21,9 @@ const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
|
||||
};
|
||||
|
||||
// Most bodies fit a narrow popover; a date range needs room for the presets
|
||||
// sidebar next to the calendar, so it gets a wider minimum.
|
||||
const DROPDOWN_WIDTH: Partial<Record<FilterDef["type"], number>> = { date: 340 };
|
||||
// sidebar next to the calendar, and a route needs room for two multi-selects'
|
||||
// worth of yard chips, so both get a wider minimum.
|
||||
const DROPDOWN_WIDTH: Partial<Record<FilterDef["type"], number>> = { date: 340, route: 320 };
|
||||
|
||||
export interface FilterPillProps {
|
||||
def: FilterDef;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DatePickerInput } from "@mantine/dates";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
|
||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||
import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates";
|
||||
import { startOfDayIso, endOfDayIso, isoToLocalDateStr, parseDateStr } from "../dates";
|
||||
import { DEFAULT_OP } from "../types";
|
||||
import type { DateFilterDef, Operator } from "../types";
|
||||
import { OperatorSelect } from "../OperatorSelect";
|
||||
@@ -15,10 +15,19 @@ import type { FilterBodyProps } from "./TextBody";
|
||||
// i18n.language !== "en" branch here when this body is first wired into a
|
||||
// record-management page (Phase 4 of the filter-bar rollout).
|
||||
export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<DateFilterDef>) {
|
||||
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.date);
|
||||
// Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects.
|
||||
const [from, setFrom] = useState<string | null>(value?.v[0]?.slice(0, 10) ?? null);
|
||||
const [to, setTo] = useState<string | null>(value?.v[1]?.slice(0, 10) ?? null);
|
||||
// DEFAULT_OP.date is always "between" — a def restricted to a single
|
||||
// non-default operator (e.g. `operators: ["before"]` for an exact-date
|
||||
// filter) would otherwise open on the range UI with no way to switch off
|
||||
// it, since OperatorSelect hides itself when there's only one choice.
|
||||
const [op, setOp] = useState<Operator>(value?.op ?? def.operators?.[0] ?? DEFAULT_OP.date);
|
||||
// Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects — and
|
||||
// the stored values are UTC instants, so they must come back through
|
||||
// `isoToLocalDateStr`, not a slice (see its comment: a slice reopens the
|
||||
// "from" side a day early east of UTC).
|
||||
const [from, setFrom] = useState<string | null>(
|
||||
value?.v[0] ? isoToLocalDateStr(value.v[0]) : null,
|
||||
);
|
||||
const [to, setTo] = useState<string | null>(value?.v[1] ? isoToLocalDateStr(value.v[1]) : null);
|
||||
|
||||
const apply = () => {
|
||||
if (op === "between") {
|
||||
|
||||
@@ -11,19 +11,29 @@ import type { FilterBodyProps } from "./TextBody";
|
||||
const SEARCH_THRESHOLD = 8;
|
||||
|
||||
/**
|
||||
* Stretches the Checkbox/Radio's native <label> across the full popover
|
||||
* width and pads it, so the clickable/tappable area is the whole row —
|
||||
* not just the ~14px input square — plus a hover cue. `body`/`labelWrapper`
|
||||
* are Mantine's part names for this; `cursor: pointer` on the row (not just
|
||||
* the input) makes the affordance visible before you even click.
|
||||
* Whole-row hit target. The only element that toggles a Mantine Checkbox /
|
||||
* Radio is its native <label>, and that label wraps its own text and nothing
|
||||
* else — the row's padding and the gutter beside the input square lie
|
||||
* OUTSIDE it. Styling those on `root` therefore bought a hover cue over an
|
||||
* area that swallowed the click.
|
||||
*
|
||||
* The fix is a `::before` stretched over the (relatively positioned) root:
|
||||
* that pseudo-element is part of the label's own box, so a click anywhere in
|
||||
* the row hits the label and toggles the input. `cursor: pointer` goes on the
|
||||
* root for the same reason — the affordance must cover what is clickable.
|
||||
*/
|
||||
const ROW_STYLES = {
|
||||
root: { padding: "10px 10px", borderRadius: 6 },
|
||||
root: { position: "relative" as const, padding: "10px 10px", borderRadius: 6, cursor: "pointer" },
|
||||
body: { alignItems: "center" as const },
|
||||
labelWrapper: { flex: 1 },
|
||||
label: { cursor: "pointer", paddingLeft: 8 },
|
||||
};
|
||||
|
||||
const ROW_CLASSES = {
|
||||
root: "hover:bg-gray-100 transition-colors",
|
||||
label: "before:absolute before:inset-0 before:content-['']",
|
||||
};
|
||||
|
||||
function OptionLabel({ label, count }: { label: string; count?: number }) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
@@ -96,7 +106,7 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<Enum
|
||||
// just the tiny checkbox square) toggles the option too.
|
||||
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
|
||||
styles={ROW_STYLES}
|
||||
classNames={{ root: "hover:bg-gray-100 transition-colors" }}
|
||||
classNames={ROW_CLASSES}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -115,7 +125,7 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<Enum
|
||||
size="sm"
|
||||
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
|
||||
styles={ROW_STYLES}
|
||||
classNames={{ root: "hover:bg-gray-100 transition-colors" }}
|
||||
classNames={ROW_CLASSES}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -1,59 +1,127 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Select, Stack } from "@mantine/core";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { ActionIcon, Button, Group, MultiSelect, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { ArrowDown, ArrowUpDown } from "lucide-react";
|
||||
|
||||
import type { RouteFilterDef } from "../types";
|
||||
import { decodeRouteValue, encodeRouteValue, type RouteSelection } from "../route";
|
||||
import type { FilterBodyProps } from "./TextBody";
|
||||
|
||||
type Side = keyof RouteSelection;
|
||||
|
||||
/**
|
||||
* Origin + destination picked together, each a searchable `Select` over the
|
||||
* page's yard list — typing filters by yard name, same as any Mantine
|
||||
* Select. No `OperatorSelect`: a route pair has exactly one operator ("is"),
|
||||
* which is why DEFAULT_OP.route is the only entry the generic bar needs.
|
||||
* Origin and destination as two independent multi-selects, either of which may
|
||||
* be left empty. That is the whole point: "everything leaving Nagad" and
|
||||
* "everything arriving at Gelan" are real questions an operator asks, and the
|
||||
* previous body — two single Selects behind an Apply gated on
|
||||
* `origin && destination` — could only ask the third one.
|
||||
*
|
||||
* Semantics are OR inside a side, AND across the two, which the hint line
|
||||
* below spells out in words rather than making the user infer it from a
|
||||
* checkbox list.
|
||||
*
|
||||
* No `OperatorSelect`: a route still has exactly one operator ("is"), which is
|
||||
* why DEFAULT_OP.route is the only entry the generic bar needs.
|
||||
*/
|
||||
export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<RouteFilterDef>) {
|
||||
const [origin, setOrigin] = useState<string | null>(value?.v[0] ?? null);
|
||||
const [destination, setDestination] = useState<string | null>(value?.v[1] ?? null);
|
||||
const [sel, setSel] = useState<RouteSelection>(() => decodeRouteValue(value?.v ?? []));
|
||||
const [search, setSearch] = useState<Record<Side, string>>({ origins: "", destinations: "" });
|
||||
|
||||
const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id;
|
||||
const list = (ids: string[]) => ids.map(label).join(" or ");
|
||||
|
||||
// Spelled out, because "OR within a side, AND across sides" is not something
|
||||
// two stacked pickers communicate on their own.
|
||||
const hint = !sel.origins.length && !sel.destinations.length
|
||||
? "Type to search stations. Pick an origin, a destination, or both."
|
||||
: !sel.destinations.length
|
||||
? `Everything leaving ${list(sel.origins)}.`
|
||||
: !sel.origins.length
|
||||
? `Everything arriving at ${list(sel.destinations)}.`
|
||||
: `From ${list(sel.origins)} to ${list(sel.destinations)}.`;
|
||||
|
||||
const apply = () => {
|
||||
onChange(origin && destination ? { op: "is", v: [origin, destination] } : undefined);
|
||||
onChange(encodeRouteValue(sel));
|
||||
onClose();
|
||||
};
|
||||
|
||||
// This popover already lives inside FilterPill's own Popover. A Select's
|
||||
// dropdown portals separately by default, so a click on an option registers
|
||||
// as "outside" the outer Popover and closes the whole filter before a pick
|
||||
// This popover already lives inside FilterPill's own Popover. A dropdown
|
||||
// portals separately by default, so a click on an option registers as
|
||||
// "outside" the outer Popover and closes the whole filter before a pick
|
||||
// lands — same nested-portal bug DateBody had. Un-portalling keeps it
|
||||
// inside the outer popover's DOM subtree instead.
|
||||
const comboboxProps = { withinPortal: false } as const;
|
||||
|
||||
/**
|
||||
* The list stays shut until there is something typed. Two open triggers have
|
||||
* to be neutralised for that, not one: `openOnFocus={false}` handles the
|
||||
* focus, but MultiSelect's PillsInput root ALSO calls `openDropdown()` on
|
||||
* every click, ungated — so the only reliable lever is driving
|
||||
* `dropdownOpened` ourselves off the search text.
|
||||
*
|
||||
* Consequence worth knowing: Mantine clears the search on each pick
|
||||
* (`clearSearchOnChange`, default true), so the list closes after one is
|
||||
* chosen and typing reopens it. That is the intended resting state — the
|
||||
* popover opens showing what is already selected, not a wall of stations.
|
||||
*/
|
||||
const sideProps = (side: Side) => ({
|
||||
data: def.options,
|
||||
placeholder: sel[side].length ? "Add another" : "Any",
|
||||
value: sel[side],
|
||||
onChange: (next: string[]) => setSel((s) => ({ ...s, [side]: next })),
|
||||
searchValue: search[side],
|
||||
onSearchChange: (q: string) => setSearch((s) => ({ ...s, [side]: q })),
|
||||
dropdownOpened: search[side].trim().length > 0,
|
||||
openOnFocus: false,
|
||||
comboboxProps,
|
||||
searchable: true,
|
||||
clearable: true,
|
||||
hidePickedOptions: true,
|
||||
maxDropdownHeight: 200,
|
||||
nothingFoundMessage: "No station matches",
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack gap="xs" w={240}>
|
||||
<Select
|
||||
label="Origin"
|
||||
placeholder="Any"
|
||||
data={def.options}
|
||||
value={origin}
|
||||
onChange={setOrigin}
|
||||
comboboxProps={comboboxProps}
|
||||
searchable
|
||||
clearable
|
||||
autoFocus
|
||||
/>
|
||||
<ArrowRight size={14} className="text-gray-400" style={{ alignSelf: "center" }} />
|
||||
<Select
|
||||
label="Destination"
|
||||
placeholder="Any"
|
||||
data={def.options}
|
||||
value={destination}
|
||||
onChange={setDestination}
|
||||
comboboxProps={comboboxProps}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Button size="sm" onClick={apply} disabled={!(origin && destination)}>
|
||||
Apply
|
||||
</Button>
|
||||
<Stack gap={6} w={300}>
|
||||
<MultiSelect label="From" autoFocus {...sideProps("origins")} />
|
||||
|
||||
<Group justify="center" gap={6} wrap="nowrap">
|
||||
<ArrowDown size={14} className="text-gray-400" />
|
||||
<Tooltip label="Swap origin and destination" withinPortal={false}>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
radius="xl"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Swap origin and destination"
|
||||
disabled={!sel.origins.length && !sel.destinations.length}
|
||||
onClick={() => setSel((s) => ({ origins: s.destinations, destinations: s.origins }))}
|
||||
>
|
||||
<ArrowUpDown size={13} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<MultiSelect label="To" {...sideProps("destinations")} />
|
||||
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{hint}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs" grow mt={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
disabled={!sel.origins.length && !sel.destinations.length}
|
||||
onClick={() => setSel({ origins: [], destinations: [] })}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
{/* Enabled even when empty: applying nothing removes the filter, which
|
||||
is how every other body's Apply behaves. */}
|
||||
<Button size="sm" onClick={apply}>
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,23 @@ export function parseDateStr(dateStr: string): Date {
|
||||
return new Date(y, (m || 1) - 1, d || 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `parseDateStr` + `startOfDayIso`/`endOfDayIso`: the LOCAL
|
||||
* `YYYY-MM-DD` an ISO instant falls on.
|
||||
*
|
||||
* `iso.slice(0, 10)` is the tempting version and it is wrong. Those instants
|
||||
* came out of `toISOString()`, so they are UTC — for Ethiopia (UTC+3) a local
|
||||
* end-of-day is `…T20:59:59.999Z` on the SAME day but a local start-of-day is
|
||||
* `…T21:00:00.000Z` on the PREVIOUS one. Slicing therefore reopens the picker
|
||||
* (and printed the pill) a day early on the "from" side only.
|
||||
*/
|
||||
export function isoToLocalDateStr(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso.slice(0, 10);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `toParams` for a date `FilterDef` widened to `["between", "before", "after"]`
|
||||
* operators. `DateBody` always emits a single-element `v` for before/after —
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { parseFilters } from "./url";
|
||||
import type { FilterDef, FilterValue } from "./types";
|
||||
import { decodeRouteValue } from "./route";
|
||||
import { OPERATOR_LABELS, type FilterDef, type FilterValue } from "./types";
|
||||
|
||||
/** Human-readable text for one filter's current value — same text a
|
||||
* FilterPill shows, and what a saved view's auto-generated label is built
|
||||
@@ -10,12 +12,29 @@ export function formatFilterValue(def: FilterDef, value: FilterValue): string {
|
||||
const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v);
|
||||
return labels.join(", ");
|
||||
}
|
||||
if (def.type === "date" && value.v.length === 2) {
|
||||
return `${value.v[0].slice(0, 10)} → ${value.v[1].slice(0, 10)}`;
|
||||
if (def.type === "date") {
|
||||
// `v` holds UTC instants (startOfDayIso/endOfDayIso call toISOString), so
|
||||
// slicing the first 10 characters printed the UTC calendar day — one day
|
||||
// EARLIER than the one picked, for anyone east of UTC. `formatDate` reads
|
||||
// the instant back in local time, which is the day the user actually chose.
|
||||
// Single-sided ops carry their operator, since "Created | Aug 20" alone
|
||||
// doesn't say whether that's a floor or a ceiling.
|
||||
const days = value.v.map(formatDate);
|
||||
if (value.op === "between" && days.length === 2) return `${days[0]} → ${days[1]}`;
|
||||
return `${OPERATOR_LABELS[value.op]} ${days[0]}`;
|
||||
}
|
||||
if (def.type === "route" && value.v.length === 2) {
|
||||
if (def.type === "route") {
|
||||
const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id;
|
||||
return `${label(value.v[0])} → ${label(value.v[1])}`;
|
||||
// "Any" reads as an unconstrained end; a long side collapses to "first +N"
|
||||
// so the pill can't grow past the rest of the bar.
|
||||
const side = (ids: string[]) =>
|
||||
ids.length === 0
|
||||
? "Any"
|
||||
: ids.length <= 2
|
||||
? ids.map(label).join(", ")
|
||||
: `${label(ids[0])} +${ids.length - 1}`;
|
||||
const { origins, destinations } = decodeRouteValue(value.v);
|
||||
return `${side(origins)} → ${side(destinations)}`;
|
||||
}
|
||||
return value.v.join(", ");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./types";
|
||||
export * from "./url";
|
||||
export * from "./dates";
|
||||
export * from "./route";
|
||||
export * from "./format";
|
||||
export * from "./clientFilter";
|
||||
export * from "./ruleEngineFooterProps";
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { FilterDef } from "./types";
|
||||
import { decodeRouteValue, encodeRouteValue, routeParams } from "./route";
|
||||
import { decodeFilterValue, encodeFilterValue, toApiParams } from "./url";
|
||||
import { formatFilterValue } from "./format";
|
||||
|
||||
const NAGAD = "11111111-1111-1111-1111-111111111111";
|
||||
const DMP = "22222222-2222-2222-2222-222222222222";
|
||||
const GELAN = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
const ROUTE: FilterDef = {
|
||||
key: "route",
|
||||
label: "Route",
|
||||
type: "route",
|
||||
options: [
|
||||
{ value: NAGAD, label: "Nagad" },
|
||||
{ value: DMP, label: "DMP" },
|
||||
{ value: GELAN, label: "Gelan" },
|
||||
],
|
||||
toParams: routeParams("originYardId", "destinationYardId"),
|
||||
};
|
||||
|
||||
describe("route filter value", () => {
|
||||
it("round-trips each side independently, through the URL codec", () => {
|
||||
const cases = [
|
||||
{ origins: [NAGAD], destinations: [] },
|
||||
{ origins: [], destinations: [GELAN] },
|
||||
{ origins: [NAGAD, DMP], destinations: [GELAN] },
|
||||
];
|
||||
for (const sel of cases) {
|
||||
const value = encodeRouteValue(sel)!;
|
||||
const raw = encodeFilterValue("route", value);
|
||||
expect(decodeRouteValue(decodeFilterValue("route", raw)!.v)).toEqual(sel);
|
||||
}
|
||||
});
|
||||
|
||||
it("is no filter at all when both sides are empty", () => {
|
||||
expect(encodeRouteValue({ origins: [], destinations: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits an unconstrained side rather than sending an empty param", () => {
|
||||
const value = encodeRouteValue({ origins: [NAGAD, DMP], destinations: [] })!;
|
||||
expect(toApiParams([ROUTE], { route: value })).toEqual({
|
||||
originYardId: `${NAGAD},${DMP}`,
|
||||
destinationYardId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("still reads the legacy untagged `route=<origin>,<destination>` pair", () => {
|
||||
expect(decodeRouteValue([NAGAD, GELAN])).toEqual({
|
||||
origins: [NAGAD],
|
||||
destinations: [GELAN],
|
||||
});
|
||||
});
|
||||
|
||||
it("labels an empty side 'Any' in the pill", () => {
|
||||
const value = encodeRouteValue({ origins: [], destinations: [GELAN] })!;
|
||||
expect(formatFilterValue(ROUTE, value)).toBe("Any → Gelan");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { FilterValue } from "./types";
|
||||
|
||||
const ORIGIN = "o:";
|
||||
const DEST = "d:";
|
||||
|
||||
export interface RouteSelection {
|
||||
origins: string[];
|
||||
destinations: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A route filter's `v` is one flat, TAGGED list — `["o:<yardId>", "d:<yardId>", …]`.
|
||||
*
|
||||
* It has to be flat because `url.ts` knows exactly one encoding for a filter
|
||||
* value: comma-split inside a single query param. The tags are what buy back
|
||||
* the two sides, and with them the three things the old fixed
|
||||
* `[origin, destination]` pair could not express:
|
||||
*
|
||||
* - origin only — "everything leaving Nagad"
|
||||
* - destination only — "everything arriving at Gelan"
|
||||
* - several yards per side — "leaving Nagad OR DMP, arriving Gelan OR Indode"
|
||||
*
|
||||
* Semantics: OR within a side, AND across the two. An empty side is not a
|
||||
* filter at all (see {@link routeParams}), never "matches nothing".
|
||||
*/
|
||||
export function decodeRouteValue(v: string[]): RouteSelection {
|
||||
const origins: string[] = [];
|
||||
const destinations: string[] = [];
|
||||
for (const entry of v) {
|
||||
if (entry.startsWith(ORIGIN)) origins.push(entry.slice(ORIGIN.length));
|
||||
else if (entry.startsWith(DEST)) destinations.push(entry.slice(DEST.length));
|
||||
}
|
||||
// Legacy `?route=<originId>,<destinationId>`: deep links and saved views
|
||||
// written before the tags existed. Untagged, and always exactly the pair.
|
||||
if (!origins.length && !destinations.length && v.length === 2) {
|
||||
return { origins: [v[0]], destinations: [v[1]] };
|
||||
}
|
||||
return { origins, destinations };
|
||||
}
|
||||
|
||||
/** Inverse of {@link decodeRouteValue}. `undefined` when both sides are empty — that is "no filter". */
|
||||
export function encodeRouteValue(sel: RouteSelection): FilterValue | undefined {
|
||||
const v = [
|
||||
...sel.origins.map((id) => `${ORIGIN}${id}`),
|
||||
...sel.destinations.map((id) => `${DEST}${id}`),
|
||||
];
|
||||
return v.length ? { op: "is", v } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `toParams` for a route filter — each side onto its own comma-separated API
|
||||
* param, mirroring `dateRangeParams`. An empty side maps to `undefined` so
|
||||
* `cleanParams` drops the param entirely; sending `originYardId=` instead
|
||||
* would have the server filter on an empty list.
|
||||
*/
|
||||
export function routeParams(
|
||||
originKey: string,
|
||||
destinationKey: string,
|
||||
): (v: FilterValue) => Record<string, string | undefined> {
|
||||
return (value) => {
|
||||
const { origins, destinations } = decodeRouteValue(value.v);
|
||||
return {
|
||||
[originKey]: origins.join(",") || undefined,
|
||||
[destinationKey]: destinations.join(",") || undefined,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -89,11 +89,11 @@ export interface BooleanFilterDef extends FilterDefBase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin + destination picked together as one pill — `v` is always the
|
||||
* 2-slot pair `[originYardId, destinationYardId]`, never partial (the body's
|
||||
* Apply button stays disabled until both sides are chosen, same rule
|
||||
* `DateBody` uses for a `between` range). One shared `options` list drives
|
||||
* both selects.
|
||||
* Origin + destination as one pill, each side holding any number of yards and
|
||||
* either side allowed to be empty. `v` is the tagged flat list described in
|
||||
* `route.ts` — use `decodeRouteValue` / `encodeRouteValue` to read or write it,
|
||||
* and `routeParams(originKey, destinationKey)` as the def's `toParams`. One
|
||||
* shared `options` list drives both sides.
|
||||
*/
|
||||
export interface RouteFilterDef extends FilterDefBase {
|
||||
type: "route";
|
||||
|
||||
@@ -571,6 +571,11 @@ export const buildSidebarSections = (
|
||||
href: "/dashboard/configuration/exchange-rate",
|
||||
permission: FREIGHT_PERMS.settings.exchangeRate.view,
|
||||
},
|
||||
{
|
||||
label: "Operating standards",
|
||||
href: "/dashboard/configuration/operations-standards",
|
||||
permission: FREIGHT_PERMS.settings.operationsStandards.view,
|
||||
},
|
||||
{
|
||||
label: "Manual payments",
|
||||
href: "/dashboard/configuration/manual-payments",
|
||||
|
||||
@@ -4,44 +4,93 @@ import { getPositionKeys } from "@/lib/permissions";
|
||||
/** One overview composition. Every backoffice user lands on exactly one of these. */
|
||||
export type OverviewLayoutKey =
|
||||
| "executive"
|
||||
| "operations"
|
||||
| "operation"
|
||||
| "occ"
|
||||
| "marketing"
|
||||
| "marketer"
|
||||
| "finance"
|
||||
| "clearance";
|
||||
|
||||
export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = {
|
||||
executive: "Executive dashboard",
|
||||
operations: "Operations dashboard",
|
||||
operation: "Operations dashboard",
|
||||
occ: "Control centre dashboard",
|
||||
marketing: "Marketing dashboard",
|
||||
marketer: "Marketing dashboard",
|
||||
finance: "Finance dashboard",
|
||||
clearance: "Clearance & logistics dashboard",
|
||||
};
|
||||
|
||||
/**
|
||||
* Role/position key → layout, in match priority order: a user holding several
|
||||
* 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. Position keys are matched too 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`.
|
||||
* 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`.
|
||||
*/
|
||||
const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [
|
||||
["edr_operations_officer", "operations"],
|
||||
["truck_machinery_chief", "operations"],
|
||||
["edr_line_staff", "occ"],
|
||||
["edr_gl_ethiopia", "clearance"],
|
||||
["edr_gl_djibouti", "clearance"],
|
||||
// ── 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_marketing", "marketing"],
|
||||
["edr_finance", "finance"],
|
||||
["edr_director", "executive"],
|
||||
["edr_ceo", "executive"],
|
||||
["edr_org_manager", "executive"],
|
||||
["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
|
||||
];
|
||||
|
||||
/** Unmapped roles (superadmin, IAM admins, new roles) keep the executive layout. */
|
||||
/** Unmapped keys (superadmin, IAM admins, Safety, new positions) keep the executive layout. */
|
||||
export function resolveOverviewLayout(
|
||||
user: AuthUser | null | undefined,
|
||||
): OverviewLayoutKey {
|
||||
|
||||
@@ -50,21 +50,15 @@ export function PageHeader({
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ minWidth: 0, maxWidth: 640 }}>
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<Title
|
||||
order={2}
|
||||
fz={24}
|
||||
fw={600}
|
||||
className="truncate"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
>
|
||||
<Title order={2} className="truncate" style={{ minWidth: 0 }}>
|
||||
{title}
|
||||
</Title>
|
||||
{meta}
|
||||
</Group>
|
||||
{subtitle ? (
|
||||
<Text c="edr-muted" fz={13} mt={4}>
|
||||
<Text c="dimmed" size="sm" mt={4} className="truncate">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
|
||||
import { DateInput, DatePickerInput } from "@mantine/dates";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||
import type { ReportFilterDef } from "@/types/reports";
|
||||
|
||||
export interface ReportFilterValues {
|
||||
[param: string]: string | undefined;
|
||||
}
|
||||
|
||||
interface ReportFiltersProps {
|
||||
filters: ReportFilterDef[];
|
||||
values: ReportFilterValues;
|
||||
onChange: (values: ReportFilterValues) => void;
|
||||
}
|
||||
|
||||
const toDate = (value: string | undefined): Date | null => (value ? new Date(value) : null);
|
||||
const fromDate = (value: string | null): string | undefined => value ?? undefined;
|
||||
|
||||
/** Renders one widget per report-declared filter and reports raw param values back up. */
|
||||
export function ReportFilters({ filters, values, onChange }: ReportFiltersProps) {
|
||||
if (!filters.length) return null;
|
||||
|
||||
const set = (patch: ReportFilterValues) => onChange({ ...values, ...patch });
|
||||
|
||||
return (
|
||||
<Group gap="sm" wrap="wrap">
|
||||
{filters.map((filter) => {
|
||||
switch (filter.type) {
|
||||
case "daterange":
|
||||
return (
|
||||
<DatePickerInput
|
||||
key={filter.key}
|
||||
type="range"
|
||||
placeholder={filter.label}
|
||||
value={[values[`${filter.key}From`] ?? null, values[`${filter.key}To`] ?? null]}
|
||||
onChange={([from, to]) =>
|
||||
set({ [`${filter.key}From`]: fromDate(from), [`${filter.key}To`]: fromDate(to) })
|
||||
}
|
||||
presets={getDateRangePresets()}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={230}
|
||||
/>
|
||||
);
|
||||
case "date":
|
||||
return (
|
||||
<DateInput
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
value={toDate(values[filter.key])}
|
||||
onChange={(d) => set({ [filter.key]: fromDate(d) })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
data={filter.options ?? []}
|
||||
value={values[filter.key] ?? null}
|
||||
onChange={(v) => set({ [filter.key]: v ?? undefined })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={170}
|
||||
/>
|
||||
);
|
||||
case "multiselect":
|
||||
return (
|
||||
<MultiSelect
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
data={filter.options ?? []}
|
||||
value={values[filter.key]?.split(",").filter(Boolean) ?? []}
|
||||
onChange={(v) => set({ [filter.key]: v.length ? v.join(",") : undefined })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<TextInput
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
leftSection={<Search size={16} />}
|
||||
value={values[filter.key] ?? ""}
|
||||
onChange={(e) => set({ [filter.key]: e.target.value || undefined })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={220}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportFilters;
|
||||
@@ -9,6 +9,8 @@ interface ReportSectionProps {
|
||||
reportKey: string;
|
||||
/** Scopes the report to one entity, e.g. the contract this page is showing. */
|
||||
idKeyValue?: string;
|
||||
/** Opens on the chart instead of the table — for dashboard tiles. */
|
||||
defaultView?: "table" | "chart";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,7 +19,7 @@ interface ReportSectionProps {
|
||||
* loading or if the caller lacks the report's permission, so pages can embed
|
||||
* it unconditionally without their own permission check.
|
||||
*/
|
||||
export function ReportSection({ reportKey, idKeyValue }: ReportSectionProps) {
|
||||
export function ReportSection({ reportKey, idKeyValue, defaultView }: ReportSectionProps) {
|
||||
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
|
||||
const def = catalog?.find((r) => r.key === reportKey);
|
||||
|
||||
@@ -31,7 +33,7 @@ export function ReportSection({ reportKey, idKeyValue }: ReportSectionProps) {
|
||||
{def.description}
|
||||
</Text>
|
||||
</div>
|
||||
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} />
|
||||
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} defaultView={defaultView} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,55 @@
|
||||
import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Column, SortingState } from "@tanstack/react-table";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { PageHeader } from "@/components/page";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
|
||||
import { api } from "@/services/api";
|
||||
import type { ReportRunParams } from "@/types/reports";
|
||||
import type { ReportFilterDef, ReportRunParams } from "@/types/reports";
|
||||
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { ReportChart } from "./ReportChart";
|
||||
import { ReportExportButton } from "./ReportExportButton";
|
||||
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
|
||||
import { formatKpiValue, formatReportCell } from "./report-format";
|
||||
|
||||
/**
|
||||
* Maps the report catalog's own filter vocabulary onto the shared FilterBar's
|
||||
* `FilterDef`. "search" is skipped — FilterBar already renders its own search
|
||||
* box wired to the same `search` param, so keeping the catalog's declared
|
||||
* "search" filter too would just double it up as a redundant pill.
|
||||
*/
|
||||
function toFilterDefs(filters: ReportFilterDef[]): FilterDef[] {
|
||||
return filters
|
||||
.filter((f) => f.key !== "search")
|
||||
.map((f): FilterDef => {
|
||||
switch (f.type) {
|
||||
case "daterange":
|
||||
return {
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
type: "date",
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams(`${f.key}From`, `${f.key}To`),
|
||||
};
|
||||
case "date":
|
||||
// Every report's single-date filter (e.g. "as of") is an exact
|
||||
// cutoff, not a range — one fixed operator keeps DateBody on its
|
||||
// single-date UI instead of the range picker.
|
||||
return { key: f.key, label: f.label, type: "date", operators: ["before"] };
|
||||
case "select":
|
||||
return { key: f.key, label: f.label, type: "enum", multiple: false, options: f.options ?? [] };
|
||||
case "multiselect":
|
||||
return { key: f.key, label: f.label, type: "enum", multiple: true, options: f.options ?? [] };
|
||||
default:
|
||||
return { key: f.key, label: f.label, type: "text" };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function SortableHeader({ label, column }: { label: string; column: Column<Record<string, unknown>, unknown> }) {
|
||||
const sorted = column.getIsSorted();
|
||||
const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
|
||||
@@ -40,6 +74,8 @@ interface ReportViewProps {
|
||||
* arrow) with export/refresh as its actions, instead of inline above the
|
||||
* table. Off by default for embedded sections. */
|
||||
pageHeader?: boolean;
|
||||
/** Opens on the chart instead of the table — for dashboard tiles. */
|
||||
defaultView?: "table" | "chart";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,15 +83,29 @@ interface ReportViewProps {
|
||||
* filters, KPI strip, sortable/paginated table or chart, xlsx/pdf export.
|
||||
* Adding a report never touches this file.
|
||||
*/
|
||||
export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProps) {
|
||||
export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: ReportViewProps) {
|
||||
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
|
||||
const def = catalog?.find((r) => r.key === reportKey);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 20 });
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
|
||||
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
|
||||
const [view, setView] = useState<"table" | "chart">("table");
|
||||
const [view, setView] = useState<"table" | "chart">(defaultView ?? "table");
|
||||
|
||||
// FilterBar's own state — reads/writes the URL directly, same as
|
||||
// BookingRequestsPage, so a drilled-into or shared report URL opens
|
||||
// already filtered.
|
||||
const reportFilterDefs = useMemo(() => toFilterDefs(def?.filters ?? []), [def?.filters]);
|
||||
const controls = useFilters(reportFilterDefs);
|
||||
// useFilters also tracks its own page/pageSize — unused here, this report
|
||||
// view paginates itself (and overrides pageSize for the chart view below).
|
||||
const filterParams = useMemo(() => {
|
||||
const rest = { ...controls.params };
|
||||
delete rest.page;
|
||||
delete rest.pageSize;
|
||||
return rest;
|
||||
}, [controls.params]);
|
||||
|
||||
// Filters + sort as the user currently has them — independent of the view
|
||||
// toggle's paging, so export always matches what's on screen either way.
|
||||
@@ -64,10 +114,18 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
|
||||
return {
|
||||
sortBy: sort?.id,
|
||||
sortOrder: sort ? (sort.desc ? "DESC" as const : "ASC" as const) : undefined,
|
||||
...debouncedFilters,
|
||||
...filterParams,
|
||||
...(def?.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
|
||||
};
|
||||
}, [def, sorting, debouncedFilters, idKeyValue]);
|
||||
}, [def, sorting, filterParams, idKeyValue]);
|
||||
|
||||
// A filter change should land back on page 1, same as every other
|
||||
// FilterBar page — but pagination here is local (not URL-driven via
|
||||
// controls.tableProps), so it needs an explicit reset.
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterParams]);
|
||||
|
||||
const runParams: ReportRunParams | undefined = useMemo(() => {
|
||||
if (!def) return undefined;
|
||||
@@ -87,6 +145,30 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
|
||||
enabled: Boolean(runParams),
|
||||
});
|
||||
|
||||
/**
|
||||
* Row click carries this row's values into the target report as filter
|
||||
* params — the "summary to transaction level" drill-down. Undefined unless
|
||||
* the report declares `drill`, which is what leaves the row unclickable.
|
||||
*/
|
||||
const handleRowClick = useMemo(() => {
|
||||
const drill = def?.drill;
|
||||
if (!drill) return undefined;
|
||||
return (row: Record<string, unknown>) => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [column, filterKey] of Object.entries(drill.carry)) {
|
||||
const value = row[column];
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
params.set(filterKey, String(value));
|
||||
}
|
||||
}
|
||||
// Carry the filters already applied, so the drill narrows rather than resets.
|
||||
for (const [key, value] of Object.entries(appliedParams)) {
|
||||
if (typeof value === "string" && value && !params.has(key)) params.set(key, value);
|
||||
}
|
||||
navigate(`/dashboard/reports/${drill.to}?${params.toString()}`);
|
||||
};
|
||||
}, [def?.drill, appliedParams, navigate]);
|
||||
|
||||
const total = data?.meta.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
@@ -167,25 +249,23 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<ReportFilters
|
||||
filters={def.filters}
|
||||
values={filterValues}
|
||||
onChange={(v) => {
|
||||
setFilterValues(v);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
{chartToggle}
|
||||
{pageHeader ? null : (
|
||||
<>
|
||||
{exportButton}
|
||||
{refreshButton}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<FilterBar
|
||||
defs={reportFilterDefs}
|
||||
controls={controls}
|
||||
// Only a handful of reports actually implement the `search`
|
||||
// param server-side (see toFilterDefs) — showing the box on
|
||||
// every report would be a dead control on the rest.
|
||||
showSearch={def.filters.some((f) => f.key === "search")}
|
||||
searchPlaceholder="Search…"
|
||||
>
|
||||
{chartToggle}
|
||||
{pageHeader ? null : (
|
||||
<>
|
||||
{exportButton}
|
||||
{refreshButton}
|
||||
</>
|
||||
)}
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
{view === "chart" && def.chart ? (
|
||||
@@ -195,6 +275,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
onRowClick={handleRowClick}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No data for the selected filters."
|
||||
error={isError ? { message: "Failed to load report.", onRetry: () => void refetch() } : undefined}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -162,16 +163,6 @@ const inputStyles = {
|
||||
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" },
|
||||
} as const;
|
||||
|
||||
const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<span>{label}</span>
|
||||
{required ? (
|
||||
<Text component="span" c="red" size="sm">
|
||||
*
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
|
||||
const RuleEngineFormDialog = ({
|
||||
open,
|
||||
@@ -417,7 +408,11 @@ const RuleEngineFormDialog = ({
|
||||
);
|
||||
}
|
||||
|
||||
const label = <FieldLabel label={field.label} required={field.required} />;
|
||||
// A plain string, so Mantine renders the label and its required asterisk
|
||||
// itself. Passing an element here put a flex box inside the <label>,
|
||||
// which added a line of dead space above every input and bumped
|
||||
// Mantine's own asterisk onto a line of its own.
|
||||
const label = field.label;
|
||||
|
||||
if (field.type === "tierList") {
|
||||
const rows = Array.isArray(values[field.name])
|
||||
@@ -525,6 +520,7 @@ const RuleEngineFormDialog = ({
|
||||
<MultiSelect
|
||||
key={field.name}
|
||||
label={label}
|
||||
withAsterisk={field.required}
|
||||
description={field.description}
|
||||
placeholder={
|
||||
selectOptionsLoading
|
||||
@@ -614,6 +610,31 @@ const RuleEngineFormDialog = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "date") {
|
||||
const raw = String(values[field.name] ?? "");
|
||||
return (
|
||||
<DateInput
|
||||
key={field.name}
|
||||
label={label}
|
||||
description={field.description}
|
||||
placeholder="Select date"
|
||||
// Mantine's DateValue accepts a `YYYY-MM-DD` string, which is exactly
|
||||
// what the API's date columns take — so the value passes straight
|
||||
// through with no Date round-trip, and none of the UTC-parsing shift
|
||||
// that `new Date("2026-01-01")` would introduce east of Greenwich.
|
||||
value={raw || null}
|
||||
onChange={(v) => setField(field.name, v ?? "")}
|
||||
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
|
||||
required={field.required}
|
||||
error={fieldErrors[field.name] || undefined}
|
||||
clearable
|
||||
size="md"
|
||||
radius="md"
|
||||
styles={inputStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isNumber = field.type === "number";
|
||||
const computed = field.computeValue ? field.computeValue(values) : undefined;
|
||||
|
||||
@@ -622,7 +643,7 @@ const RuleEngineFormDialog = ({
|
||||
key={field.name}
|
||||
label={label}
|
||||
description={field.description}
|
||||
type={isNumber ? "number" : field.type === "date" ? "date" : "text"}
|
||||
type={isNumber ? "number" : "text"}
|
||||
// Every rule-engine number (sizes, capacities, counts, points, rates,
|
||||
// display order) is a non-negative magnitude — reject negatives outright
|
||||
// rather than letting a typed "-" reach the API.
|
||||
|
||||
@@ -201,16 +201,20 @@ export function StatTile({
|
||||
/**
|
||||
* Origin → destination corridor visual: two anchored stops joined by a rail
|
||||
* line. `variant="compact"` is for dense table rows; `default` for cards.
|
||||
* `orientation="vertical"` stacks the stops as waypoints, which keeps a long
|
||||
* yard name off one wide line in a table cell.
|
||||
*/
|
||||
export function RouteCorridor({
|
||||
origin,
|
||||
destination,
|
||||
variant = "default",
|
||||
orientation = "horizontal",
|
||||
onDark = false,
|
||||
}: {
|
||||
origin?: string | null;
|
||||
destination?: string | null;
|
||||
variant?: "default" | "compact";
|
||||
orientation?: "horizontal" | "vertical";
|
||||
onDark?: boolean;
|
||||
}) {
|
||||
const compact = variant === "compact";
|
||||
@@ -218,6 +222,48 @@ export function RouteCorridor({
|
||||
const strong = onDark ? "white" : "var(--mantine-color-gray-8)";
|
||||
const lineColor = onDark ? "rgba(255,255,255,0.4)" : "var(--mantine-color-gray-3)";
|
||||
const accent = onDark ? "white" : freightBrand.primary;
|
||||
const dot = compact ? 7 : 9;
|
||||
|
||||
if (orientation === "vertical") {
|
||||
return (
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={dot}
|
||||
h={dot}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
border: `2px solid ${accent}`,
|
||||
background: onDark ? "transparent" : "white",
|
||||
}}
|
||||
/>
|
||||
<Text size="sm" fw={600} c={strong} lh={1.2} truncate>
|
||||
{origin ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
{/* Rail between the stops, aligned to the dot centres. */}
|
||||
<Box
|
||||
ml={dot / 2 - 1}
|
||||
style={{
|
||||
width: 0,
|
||||
height: compact ? 10 : 14,
|
||||
borderLeft: `2px dashed ${lineColor}`,
|
||||
}}
|
||||
/>
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={dot}
|
||||
h={dot}
|
||||
style={{ borderRadius: 999, flexShrink: 0, background: accent }}
|
||||
/>
|
||||
<Text size="sm" fw={600} c={strong} lh={1.2} truncate>
|
||||
{destination ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={compact ? 6 : 8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
CopyButton,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
@@ -12,20 +14,25 @@ import {
|
||||
NumberInput,
|
||||
ScrollArea,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Calendar,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
Copy,
|
||||
Eye,
|
||||
FileText,
|
||||
History,
|
||||
@@ -83,6 +90,9 @@ import { StoreInventoryModal } from './StoreInventoryModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
import '@/components/overview/overview.css';
|
||||
|
||||
type ImportUnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
|
||||
@@ -915,7 +925,10 @@ function EligibleTab({
|
||||
),
|
||||
[rows, statusOptions],
|
||||
);
|
||||
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
|
||||
const controls = useListControls(statusFilteredRows, {
|
||||
searchKeys: ['reference', 'customer', 'origin', 'destination', 'containerNumber', 'cargo', 'cargoDescription'],
|
||||
});
|
||||
const selectableRows = controls.filteredRows.filter(canReceiveBooking);
|
||||
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const pendingReceiveRows = useMemo(
|
||||
@@ -1075,7 +1088,7 @@ function EligibleTab({
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
|
||||
Selected: <b>{selected.size}</b> / {controls.filteredRows.length} eligible
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
@@ -1118,6 +1131,19 @@ function EligibleTab({
|
||||
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Booking, customer, route, container, cargo…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
showDateRange={false}
|
||||
/>
|
||||
<Table.ScrollContainer minWidth={1350}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
@@ -1146,7 +1172,7 @@ function EligibleTab({
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{statusFilteredRows.map((r) => {
|
||||
{controls.pagedRows.map((r) => {
|
||||
const canReceive = canReceiveBooking(r);
|
||||
return (
|
||||
<Table.Tr key={r.id}>
|
||||
@@ -1242,6 +1268,14 @@ function EligibleTab({
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="bookings"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
@@ -1340,7 +1374,10 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
|
||||
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
|
||||
const controls = useListControls(rows, {
|
||||
searchKeys: ['bookingReference', 'customerName', 'containerNumber', 'cargoType', 'grnNumber', 'origin', 'destination'],
|
||||
});
|
||||
const pendingRows = controls.filteredRows.filter((r) => r.inspectionStatus !== 'PASSED');
|
||||
const allSelected = pendingRows.length > 0 && selected.size === pendingRows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleAll = () =>
|
||||
@@ -1404,6 +1441,19 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
No received export items awaiting inspection.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Booking, GRN, customer, container, cargo, route…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
showDateRange={false}
|
||||
/>
|
||||
<Table.ScrollContainer minWidth={1350}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
@@ -1430,7 +1480,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r: ReadyToLoadRow) => {
|
||||
{controls.pagedRows.map((r: ReadyToLoadRow) => {
|
||||
const selectable = r.inspectionStatus !== 'PASSED';
|
||||
return (
|
||||
<Fragment key={r.id}>
|
||||
@@ -1493,6 +1543,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="items"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<InspectionReportModal
|
||||
@@ -1517,9 +1575,12 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const controls = useListControls(rows, {
|
||||
searchKeys: ['bookingReference', 'customerName', 'containerNumber', 'cargoType', 'grnNumber', 'origin', 'destination'],
|
||||
});
|
||||
const allSelected = controls.filteredRows.length > 0 && selected.size === controls.filteredRows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
||||
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(controls.filteredRows.map((r) => r.id)));
|
||||
const toggleOne = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -1589,9 +1650,9 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{selected.size > 0 ? (
|
||||
<><b>{selected.size}</b> of {rows.length} selected</>
|
||||
<><b>{selected.size}</b> of {controls.filteredRows.length} selected</>
|
||||
) : (
|
||||
<><b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} ready to load</>
|
||||
<><b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load</>
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
@@ -1660,6 +1721,19 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
No EXPORT items with inspection PASSED waiting to be loaded.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Booking, GRN, customer, container, cargo, route…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
showDateRange={false}
|
||||
/>
|
||||
<Table.ScrollContainer minWidth={1200}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
@@ -1685,7 +1759,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r: ReadyToLoadRow) => (
|
||||
{controls.pagedRows.map((r: ReadyToLoadRow) => (
|
||||
<Fragment key={r.id}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
@@ -1739,6 +1813,14 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="items"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
@@ -1768,9 +1850,12 @@ function LoadedExportTab({
|
||||
);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const controls = useListControls(rows, {
|
||||
searchKeys: ['bookingReference', 'customerName', 'containerNumber', 'cargoType', 'grnNumber', 'origin', 'destination'],
|
||||
});
|
||||
const allSelected = controls.filteredRows.length > 0 && selected.size === controls.filteredRows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
||||
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(controls.filteredRows.map((r) => r.id)));
|
||||
const toggleOne = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -1802,11 +1887,11 @@ function LoadedExportTab({
|
||||
<Text size="sm" c="dimmed">
|
||||
{dispatchable ? (
|
||||
<>
|
||||
Selected: <b>{selected.size}</b> / {rows.length} loaded
|
||||
Selected: <b>{selected.size}</b> / {controls.filteredRows.length} loaded
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} loaded
|
||||
<b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} loaded
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
@@ -1858,6 +1943,19 @@ function LoadedExportTab({
|
||||
No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Booking, GRN, customer, container, cargo, route…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
showDateRange={false}
|
||||
/>
|
||||
<Table.ScrollContainer minWidth={1200}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
@@ -1884,7 +1982,7 @@ function LoadedExportTab({
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r: ReadyToLoadRow) => (
|
||||
{controls.pagedRows.map((r: ReadyToLoadRow) => (
|
||||
<Fragment key={r.id}>
|
||||
<Table.Tr>
|
||||
{dispatchable && (
|
||||
@@ -1935,6 +2033,14 @@ function LoadedExportTab({
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="items"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||
</Stack>
|
||||
@@ -2218,6 +2324,11 @@ export function ImportArriveQueueTab({
|
||||
>({});
|
||||
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
|
||||
|
||||
const controls = useListControls(trains, {
|
||||
searchKeys: ['trainNumber', 'route', 'origin', 'destination', 'status'],
|
||||
dateKey: 'arrivalTime',
|
||||
});
|
||||
|
||||
const autoUnload = async (train: ImportTrain) => {
|
||||
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
|
||||
.filter((entry): entry is [string, Required<ImportUnloadAssignmentDraft>] =>
|
||||
@@ -2272,10 +2383,6 @@ export function ImportArriveQueueTab({
|
||||
|
||||
return (
|
||||
<Stack gap="sm" mt="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
<b>{trains.length}</b> arrived import train{trains.length !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
@@ -2285,6 +2392,25 @@ export function ImportArriveQueueTab({
|
||||
No arrived import trains. Trains appear here once their schedule status is ARRIVED.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
<b>{controls.totalCount}</b> arrived import train{controls.totalCount !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Train #, route, origin, destination…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Arrival"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Table.ScrollContainer minWidth={1500}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
@@ -2303,7 +2429,16 @@ export function ImportArriveQueueTab({
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{trains.map((t: ImportTrain) => {
|
||||
{controls.pagedRows.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={11}>
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
No trains match the current filters.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
controls.pagedRows.map((t: ImportTrain) => {
|
||||
const isOpen = openId === t.scheduleId;
|
||||
const fullyUnloaded = isFullyUnloaded(t);
|
||||
const unloadedBookings = t.unloadedBookings ?? t.totalBookings - getPendingUnloadBookings(t);
|
||||
@@ -2311,7 +2446,18 @@ export function ImportArriveQueueTab({
|
||||
<Fragment key={t.scheduleId}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{t.scheduleId.slice(0, 8)}…</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">{t.scheduleId.slice(0, 8)}…</Text>
|
||||
<CopyButton value={t.scheduleId}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? 'Copied' : 'Copy schedule ID'} withArrow>
|
||||
<ActionIcon size="xs" variant="subtle" color={copied ? 'teal' : 'gray'} onClick={copy}>
|
||||
{copied ? <CheckCheck size={12} /> : <Copy size={12} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{t.trainNumber ?? '—'}</Text>
|
||||
@@ -2390,10 +2536,19 @@ export function ImportArriveQueueTab({
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
}))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="trains"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||
</Stack>
|
||||
@@ -2872,6 +3027,41 @@ interface WarehouseFlowWorkbenchProps {
|
||||
focusedBookingLabel?: string;
|
||||
}
|
||||
|
||||
function WarehouseStatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
sub: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon color={color} variant="light" size={40} radius="md">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={800} fz={22} lh={1.1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{sub}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function WarehouseQueueTabs<TValue extends string>({
|
||||
value,
|
||||
onChange,
|
||||
@@ -2939,6 +3129,7 @@ function LocateBookingTab({ enabled }: { enabled: boolean }) {
|
||||
applied.status,
|
||||
);
|
||||
const { data: results = [], isFetching } = useInventoryInquiry(applied, enabled && hasSearch);
|
||||
const controls = useListControls(results);
|
||||
|
||||
const normalizeDraft = (): InventoryInquiryFilter => ({
|
||||
bookingReference: draft.bookingReference?.trim() || undefined,
|
||||
@@ -3022,7 +3213,16 @@ function LocateBookingTab({ enabled }: { enabled: boolean }) {
|
||||
No inventory found for the current filters.
|
||||
</Text>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} onView={setViewResult} />
|
||||
<Stack gap="sm">
|
||||
<WarehouseInquiryTable results={controls.pagedRows} onView={setViewResult} />
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="results"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<InventoryInquiryDetailModal
|
||||
@@ -3039,6 +3239,7 @@ function ImportWarehouseTabs({ enabled, onChanged }: { enabled: boolean; onChang
|
||||
const { data: arriveRows = [] } = useQuery(api.warehouses.importArriveQueue.queryOptions({ enabled }));
|
||||
const { data: unloadedRows = [] } = useQuery(api.warehouses.importUnloadedQueue.queryOptions({ enabled }));
|
||||
const { data: dispatchRows = [] } = useQuery(api.warehouses.importPickupReadyQueue.queryOptions({ enabled }));
|
||||
const totalBookings = arriveRows.reduce((sum, t) => sum + t.totalBookings, 0);
|
||||
const tabs: WarehouseQueueTab<ImportWarehouseTab>[] = [
|
||||
{
|
||||
value: 'arrive-queue',
|
||||
@@ -3067,6 +3268,13 @@ function ImportWarehouseTabs({ enabled, onChanged }: { enabled: boolean; onChang
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="sm">
|
||||
<WarehouseStatCard icon={<PackageOpen size={18} />} label="Arrived" value={arriveRows.length} sub="Import trains" color="edr-green" />
|
||||
<WarehouseStatCard icon={<ClipboardCheck size={18} />} label="Unloaded" value={unloadedRows.length} sub="Import trains" color="blue" />
|
||||
<WarehouseStatCard icon={<Send size={18} />} label="Dispatch Ready" value={dispatchRows.length} sub="Import trains" color="violet" />
|
||||
<WarehouseStatCard icon={<Calendar size={18} />} label="Total Bookings" value={totalBookings} sub="Across arrived trains" color="orange" />
|
||||
</SimpleGrid>
|
||||
|
||||
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
|
||||
|
||||
{activeTab === 'arrive-queue' && (
|
||||
@@ -3152,6 +3360,14 @@ function ExportWarehouseTabs({
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 5 }} spacing="sm">
|
||||
<WarehouseStatCard icon={<Truck size={18} />} label="Eligible" value={exportEligibleCount} sub="Export bookings" color="edr-green" />
|
||||
<WarehouseStatCard icon={<ClipboardCheck size={18} />} label="Received" value={receivedRows.length} sub="Export bookings" color="blue" />
|
||||
<WarehouseStatCard icon={<Train size={18} />} label="Ready To Load" value={readyRows.length} sub="Export bookings" color="teal" />
|
||||
<WarehouseStatCard icon={<PackageCheck size={18} />} label="Loaded" value={loadedRows.length} sub="Export bookings" color="indigo" />
|
||||
<WarehouseStatCard icon={<Send size={18} />} label="Dispatch Ready" value={loadedRows.length} sub="Export bookings" color="violet" />
|
||||
</SimpleGrid>
|
||||
|
||||
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
|
||||
|
||||
{activeTab === 'receive-queue' && (
|
||||
|
||||
Reference in New Issue
Block a user