mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
style: ui fixes
This commit is contained in:
@@ -61,6 +61,7 @@ import {
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { formatDateTime, formatMoney } from "@/lib/format";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import {
|
||||
@@ -186,7 +187,7 @@ export default function BookingRequestDetailPage() {
|
||||
const kpis: KpiItem[] = [
|
||||
{
|
||||
label: "Total value",
|
||||
value: `${booking.paymentCurrency} ${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`,
|
||||
value: formatMoney(amount, booking.paymentCurrency, 2),
|
||||
hint: booking.paymentStatus,
|
||||
icon: Wallet,
|
||||
color: "edr-green",
|
||||
@@ -326,7 +327,7 @@ export default function BookingRequestDetailPage() {
|
||||
|
||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||
<Text size="xs" c="orange.7">
|
||||
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
|
||||
Hold expires {formatDateTime(booking.holdExpiresAt)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
@@ -36,6 +37,8 @@ import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { FilterToggle } from "@/components/common/FilterToggle";
|
||||
import { formatDate, humanize } from "@/lib/format";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
||||
@@ -50,7 +53,6 @@ import {
|
||||
useBookingList,
|
||||
useBookingListSummary,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
@@ -116,18 +118,6 @@ function endOfDayIso(d: Date): string {
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function BookingRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
|
||||
@@ -159,6 +149,11 @@ export default function BookingRequestsPage() {
|
||||
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
||||
const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null);
|
||||
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
|
||||
// Direction is the only deep-linkable advanced filter — open the panel so a
|
||||
// deep link never hides its own filter.
|
||||
const [showAdvanced, setShowAdvanced] = useState(() =>
|
||||
Boolean(paramDirection),
|
||||
);
|
||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
||||
// Paid bookings with no train attached (staff removed them or a sweep
|
||||
@@ -185,6 +180,7 @@ export default function BookingRequestsPage() {
|
||||
const next = paramStatuses.split(",").filter(Boolean);
|
||||
setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next));
|
||||
setDirectionFilter(paramDirection);
|
||||
if (paramDirection) setShowAdvanced(true);
|
||||
}, [paramStatuses, paramDirection]);
|
||||
|
||||
const filter: BookingListFilter = useMemo(() => {
|
||||
@@ -278,6 +274,10 @@ export default function BookingRequestsPage() {
|
||||
(createdFrom || createdTo ? 1 : 0) +
|
||||
(scheduledFrom || scheduledTo ? 1 : 0);
|
||||
|
||||
// Badge on the advanced-filters toggle — active filters hidden behind it.
|
||||
const advancedFilterCount =
|
||||
activeFilterCount - (kindFilter ? 1 : 0) - (statusFilter.length ? 1 : 0);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setKindFilter(null);
|
||||
setStatusFilter([]);
|
||||
@@ -390,13 +390,22 @@ export default function BookingRequestsPage() {
|
||||
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const isGeneral = b.bookingKind === "GENERAL_CONTRACT";
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<Package className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">{b.reference}</p>
|
||||
<div className="min-w-0 max-w-[220px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate font-medium text-foreground">{b.reference}</p>
|
||||
<Badge
|
||||
variant={isGeneral ? "secondary" : "outline"}
|
||||
className="h-5 shrink-0 px-1.5 text-[10px] font-medium"
|
||||
>
|
||||
{isGeneral ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{b.customerLabel}
|
||||
@@ -406,49 +415,6 @@ export default function BookingRequestsPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => {
|
||||
const ref = row.original.contractReference;
|
||||
return (
|
||||
<div className="py-1">
|
||||
{ref ? (
|
||||
// Fall back to plain text when the id is missing — the reference is
|
||||
// still worth showing, it just has nowhere to link to.
|
||||
(row.original.contractId ? (
|
||||
<ContractReferenceLink
|
||||
contractId={row.original.contractId}
|
||||
contractReference={ref}
|
||||
className="truncate font-mono text-xs text-foreground underline underline-offset-2 hover:text-primary"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bookingKind",
|
||||
header: () => <span className={bookingTable.headerCell}>Type</span>,
|
||||
cell: ({ row }) => {
|
||||
const isGeneral = row.original.bookingKind === "GENERAL_CONTRACT";
|
||||
return (
|
||||
<div className="py-1">
|
||||
<Badge
|
||||
variant={isGeneral ? "secondary" : "outline"}
|
||||
className="h-5 px-1.5 text-[10px] font-medium"
|
||||
>
|
||||
{isGeneral ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
@@ -464,15 +430,15 @@ export default function BookingRequestsPage() {
|
||||
<div className="flex gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium backdrop-blur-sm"
|
||||
>
|
||||
{b.tradeDirection}
|
||||
{humanize(b.tradeDirection)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
||||
>
|
||||
{b.freightType}
|
||||
{humanize(b.freightType)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -621,7 +587,7 @@ export default function BookingRequestsPage() {
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search booking, contract or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
@@ -649,11 +615,6 @@ export default function BookingRequestsPage() {
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All booking types"
|
||||
data={BOOKING_KIND_OPTIONS}
|
||||
@@ -679,6 +640,25 @@ export default function BookingRequestsPage() {
|
||||
radius="lg"
|
||||
style={{ minWidth: 220 }}
|
||||
/>
|
||||
<FilterToggle
|
||||
count={advancedFilterCount}
|
||||
expanded={showAdvanced}
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
<Collapse expanded={showAdvanced}>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All origins"
|
||||
data={yardOptions}
|
||||
@@ -729,8 +709,6 @@ export default function BookingRequestsPage() {
|
||||
radius="lg"
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All payment statuses"
|
||||
data={PAYMENT_STATUS_OPTIONS}
|
||||
@@ -793,18 +771,8 @@ export default function BookingRequestsPage() {
|
||||
radius="lg"
|
||||
style={{ minWidth: 230 }}
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -471,9 +471,6 @@ export default function DocumentClearanceListPage({
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
|
||||
@@ -42,6 +42,7 @@ import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { api as appApi } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { customersService } from "@/services/customers.service";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
interface RefNamed {
|
||||
id: string;
|
||||
@@ -705,7 +706,7 @@ export default function NewBookingPage() {
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Departure"
|
||||
value={effectiveDepartureIso ? new Date(effectiveDepartureIso).toLocaleString() : "Not set"}
|
||||
value={effectiveDepartureIso ? formatDateTime(effectiveDepartureIso) : "Not set"}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { api } from "@/auth/http";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { toDayString } from "@/hooks/useListControls";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -96,24 +97,6 @@ function StatusChip({ status }: { status: WagonCancellationStatus }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, currency: string): string {
|
||||
return `${currency} ${Number(amount).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff view of partial wagon cancellations: every slice of capacity a
|
||||
* customer gave back, its cancellation fee, and where the credit went
|
||||
@@ -196,7 +179,9 @@ export default function WagonCancellationsPage() {
|
||||
id: "company",
|
||||
header: () => <span>Company</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{row.original.booking?.company?.name ?? "—"}</Text>
|
||||
<Text size="sm" truncate maw={200}>
|
||||
{row.original.booking?.company?.name ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -209,7 +194,7 @@ export default function WagonCancellationsPage() {
|
||||
header: () => <span>Fee</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatAmount(row.original.feeAmount, row.original.feeCurrency)}
|
||||
{formatMoney(row.original.feeAmount, row.original.feeCurrency, 2)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
@@ -218,7 +203,7 @@ export default function WagonCancellationsPage() {
|
||||
header: () => <span>Credit</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatAmount(row.original.creditAmount, row.original.feeCurrency)}
|
||||
{formatMoney(row.original.creditAmount, row.original.feeCurrency, 2)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
@@ -370,7 +355,7 @@ export default function WagonCancellationsPage() {
|
||||
<Text size="sm">
|
||||
{voiding.booking?.reference ?? voiding.bookingId} ·{" "}
|
||||
{voiding.wagonsCancelled} wagon(s) · fee{" "}
|
||||
{formatAmount(voiding.feeAmount, voiding.feeCurrency)}
|
||||
{formatMoney(voiding.feeAmount, voiding.feeCurrency, 2)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
The pending fee is dropped and the wagons stay on the booking.
|
||||
|
||||
@@ -305,9 +305,6 @@ export default function ClearanceDocumentsPage() {
|
||||
w={220}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" mt="sm" wrap="wrap">
|
||||
<Select
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { directionLabel } from "@/lib/utils";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -114,33 +115,6 @@ const CLEARANCE_REVIEW_STATUSES = [
|
||||
...CLEARANCE_DONE_STATUSES,
|
||||
];
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Same, plus the clock — for values the staff pick to the minute. */
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContractRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Select,
|
||||
@@ -32,29 +33,25 @@ import {
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
|
||||
import {
|
||||
ContractCourtBadge,
|
||||
ContractStatusBadge,
|
||||
} from "@/components/contracts/ContractStatusBadge";
|
||||
import {
|
||||
ContractStatusTabs,
|
||||
type ContractStatusTabKey,
|
||||
} from "@/components/contracts/ContractStatusTabs";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import { FilterToggle } from "@/components/common/FilterToggle";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import {
|
||||
CONTRACT_LIST_TABS,
|
||||
CONTRACT_STATUS_STYLES,
|
||||
contractCourt,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import {
|
||||
getStaffRowAction,
|
||||
toContractListRow,
|
||||
type ContractListRow,
|
||||
} from "@/features/contracts/mapContractListRow";
|
||||
import { formatDate, humanize } from "@/lib/format";
|
||||
import {
|
||||
useContractList,
|
||||
useContractListSummary,
|
||||
@@ -68,25 +65,13 @@ import {
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
|
||||
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
|
||||
if (!match?.statuses?.length) return undefined;
|
||||
return match.statuses.join(",");
|
||||
}
|
||||
|
||||
/** Statuses selectable in the status filter for a given tab ("all" → every tab status). */
|
||||
function getStatusOptionsForTab(
|
||||
tab: ContractStatusTabKey,
|
||||
): { value: string; label: string }[] {
|
||||
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
|
||||
const statuses = match?.statuses?.length
|
||||
? match.statuses
|
||||
: CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []);
|
||||
return statuses.map((s) => ({
|
||||
/** Every filterable status — the pill tabs are gone, so the select carries them all. */
|
||||
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
|
||||
(s) => ({
|
||||
value: s,
|
||||
label: CONTRACT_STATUS_STYLES[s]?.label ?? s,
|
||||
}));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const TRADE_DIRECTION_OPTIONS = [
|
||||
{ value: "IMPORT", label: "Import" },
|
||||
@@ -117,8 +102,11 @@ const SORT_OPTIONS = [
|
||||
{ value: "contractValidUntil:DESC", label: "Expiring latest" },
|
||||
];
|
||||
|
||||
/** Every column is 120px wide and wraps its content instead of truncating. */
|
||||
const COLUMN_WIDTH = 120;
|
||||
/**
|
||||
* Per-column widths — they must sum to the table's min-w (960px, set on the
|
||||
* containerClassName below) because table-fixed distributes any difference.
|
||||
*/
|
||||
const COLUMN_WIDTHS = { contract: 250, route: 270, status: 250, validity: 190 };
|
||||
const COLUMN_META = {
|
||||
headerClassName: "whitespace-normal break-words",
|
||||
cellClassName: "whitespace-normal break-words align-top",
|
||||
@@ -138,24 +126,11 @@ function endOfDayIso(d: Date): string {
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContractRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
|
||||
// Filter controls (empty/null = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const { filterOptions } = useMyTradeAccess();
|
||||
@@ -168,12 +143,8 @@ export default function ContractRequestsPage() {
|
||||
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
||||
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
||||
const [sort, setSort] = useState<string>("createdAt:DESC");
|
||||
|
||||
const tabStatuses = getStatusesForTab(activeTab);
|
||||
const statusOptions = useMemo(
|
||||
() => getStatusOptionsForTab(activeTab),
|
||||
[activeTab],
|
||||
);
|
||||
// All filters start empty (no URL params on this page), so collapsed is safe.
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const resetPage = useCallback(() => {
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
@@ -186,16 +157,11 @@ export default function ContractRequestsPage() {
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
tab: activeTab,
|
||||
// Kept as the React Query cache-key discriminator (tabs themselves are gone).
|
||||
tab: "all",
|
||||
// Server-side free-text search (contract reference, customer name).
|
||||
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
|
||||
// Explicit status picks narrow within the tab; otherwise the tab's
|
||||
// status group applies.
|
||||
...(statusFilter.length
|
||||
? { statuses: statusFilter.join(",") }
|
||||
: tabStatuses
|
||||
? { statuses: tabStatuses }
|
||||
: {}),
|
||||
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
|
||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||
...(kindFilter ? { contractKind: kindFilter } : {}),
|
||||
@@ -206,8 +172,6 @@ export default function ContractRequestsPage() {
|
||||
}, [
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
activeTab,
|
||||
tabStatuses,
|
||||
debouncedQuery,
|
||||
statusFilter,
|
||||
directionFilter,
|
||||
@@ -227,6 +191,10 @@ export default function ContractRequestsPage() {
|
||||
(currencyFilter ? 1 : 0) +
|
||||
(createdFrom || createdTo ? 1 : 0);
|
||||
|
||||
// Badge on the advanced-filters toggle — active filters hidden behind it.
|
||||
const advancedFilterCount =
|
||||
activeFilterCount - (statusFilter.length ? 1 : 0) - (kindFilter ? 1 : 0);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setStatusFilter([]);
|
||||
setDirectionFilter(null);
|
||||
@@ -273,7 +241,7 @@ export default function ContractRequestsPage() {
|
||||
const columns: ColumnDef<ContractListRow>[] = [
|
||||
{
|
||||
id: "contract",
|
||||
size: COLUMN_WIDTH,
|
||||
size: COLUMN_WIDTHS.contract,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
||||
cell: ({ row }) => {
|
||||
@@ -296,7 +264,7 @@ export default function ContractRequestsPage() {
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
size: COLUMN_WIDTH,
|
||||
size: COLUMN_WIDTHS.route,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
@@ -311,7 +279,7 @@ export default function ContractRequestsPage() {
|
||||
<div className="flex gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium backdrop-blur-sm"
|
||||
>
|
||||
{directionLabel(c.tradeDirection)}
|
||||
</Badge>
|
||||
@@ -319,7 +287,7 @@ export default function ContractRequestsPage() {
|
||||
variant="secondary"
|
||||
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
||||
>
|
||||
{c.freightType}
|
||||
{humanize(c.freightType)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,47 +296,77 @@ export default function ContractRequestsPage() {
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: COLUMN_WIDTH,
|
||||
size: COLUMN_WIDTHS.status,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<ContractStatusBadge
|
||||
status={row.original.status}
|
||||
isRenewal={row.original.isRenewal}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "court",
|
||||
size: COLUMN_WIDTH,
|
||||
meta: COLUMN_META,
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Waiting on</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<ContractCourtBadge status={row.original.status} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
size: COLUMN_WIDTH,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Approval</span>,
|
||||
cell: ({ row }) => <ContractApprovalProgressCell row={row.original} />,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
const court = contractCourt(c.status);
|
||||
const progress = formatContractApprovalProgress(
|
||||
c.status,
|
||||
c.approvalSteps,
|
||||
);
|
||||
const action = getStaffRowAction(c);
|
||||
// One dimmed line: who it's waiting on, the staff verb, and the
|
||||
// approval chain when one exists. "Open" adds nothing — row click
|
||||
// already opens the detail page.
|
||||
const pieces: ReactNode[] = [];
|
||||
if (court) {
|
||||
pieces.push(court === "customer" ? "With customer" : "With EDR");
|
||||
}
|
||||
if (action && action.variant === "filled") {
|
||||
pieces.push(
|
||||
// "View & sign" pointed at the contract-view page, not the
|
||||
// detail page — keep that deep link as an inline link.
|
||||
action.to(c.id).endsWith("/view") ? (
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-2 hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(action.to(c.id));
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
) : (
|
||||
action.label
|
||||
),
|
||||
);
|
||||
}
|
||||
if ((c.approvalSteps ?? []).length > 0) {
|
||||
pieces.push(progress.label);
|
||||
if (!progress.complete && progress.detail.startsWith("Next:")) {
|
||||
pieces.push(progress.detail);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1 py-1">
|
||||
<ContractStatusBadge status={c.status} isRenewal={c.isRenewal} />
|
||||
{pieces.length ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pieces.map((piece, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 ? " · " : null}
|
||||
{piece}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "validity",
|
||||
size: COLUMN_WIDTH,
|
||||
size: COLUMN_WIDTHS.validity,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Validity</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
const isGeneral = c.contractKind === "GENERAL";
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Stack gap={2} align="flex-start">
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<CalendarClock className="size-3.5" />
|
||||
{c.validUntil
|
||||
@@ -382,58 +380,22 @@ export default function ContractRequestsPage() {
|
||||
From {formatDate(c.validFrom)}
|
||||
</Text>
|
||||
) : null}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
|
||||
>
|
||||
{isGeneral ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Repeat className="size-3" /> General
|
||||
</span>
|
||||
) : (
|
||||
"One-time"
|
||||
)}
|
||||
</Badge>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
size: COLUMN_WIDTH,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => {
|
||||
const isGeneral = row.original.contractKind === "GENERAL";
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
|
||||
>
|
||||
{isGeneral ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Repeat className="size-3" /> General
|
||||
</span>
|
||||
) : (
|
||||
"One-time"
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: COLUMN_WIDTH,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Action</span>,
|
||||
cell: ({ row }) => {
|
||||
const action = getStaffRowAction(row.original);
|
||||
if (!action) return null;
|
||||
return (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
variant={action.variant === "filled" ? "filled" : action.variant}
|
||||
color="edr-green"
|
||||
onClick={(e) => {
|
||||
// Don't let the row-click navigation fire as well.
|
||||
e.stopPropagation();
|
||||
navigate(action.to(row.original.id));
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -486,22 +448,11 @@ export default function ContractRequestsPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<ContractStatusTabs
|
||||
active={activeTab}
|
||||
onChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
// Status picks belong to the previous tab's option set — reset.
|
||||
setStatusFilter([]);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
@@ -541,16 +492,11 @@ export default function ContractRequestsPage() {
|
||||
style={{ minWidth: 170 }}
|
||||
aria-label="Sort contracts"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<MultiSelect
|
||||
placeholder={
|
||||
statusFilter.length ? undefined : "All statuses"
|
||||
}
|
||||
data={statusOptions}
|
||||
data={STATUS_OPTIONS}
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v);
|
||||
@@ -562,6 +508,38 @@ export default function ContractRequestsPage() {
|
||||
style={{ minWidth: 220 }}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
<Select
|
||||
placeholder="All kinds"
|
||||
data={CONTRACT_KIND_OPTIONS}
|
||||
value={kindFilter}
|
||||
onChange={(v) => {
|
||||
setKindFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 160 }}
|
||||
aria-label="Filter by contract kind"
|
||||
/>
|
||||
<FilterToggle
|
||||
count={advancedFilterCount}
|
||||
expanded={showAdvanced}
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
<Collapse expanded={showAdvanced}>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All directions"
|
||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||
@@ -588,19 +566,6 @@ export default function ContractRequestsPage() {
|
||||
style={{ minWidth: 160 }}
|
||||
aria-label="Filter by freight type"
|
||||
/>
|
||||
<Select
|
||||
placeholder="All kinds"
|
||||
data={CONTRACT_KIND_OPTIONS}
|
||||
value={kindFilter}
|
||||
onChange={(v) => {
|
||||
setKindFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 160 }}
|
||||
aria-label="Filter by contract kind"
|
||||
/>
|
||||
<Select
|
||||
placeholder="All currencies"
|
||||
data={CURRENCY_OPTIONS}
|
||||
@@ -629,18 +594,8 @@ export default function ContractRequestsPage() {
|
||||
style={{ minWidth: 220 }}
|
||||
aria-label="Created date range"
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -672,7 +627,7 @@ export default function ContractRequestsPage() {
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
// table-fixed makes the per-column 120px widths stick; without
|
||||
// table-fixed makes the per-column widths stick; without
|
||||
// it auto-layout re-widens columns once cells wrap.
|
||||
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
|
||||
footer={DataTableFooter}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { directionLabel } from "@/lib/utils";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -51,18 +52,6 @@ const prettyStatus = (s?: string | null) =>
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function yardLabel(
|
||||
yard?: { label?: string; code?: string; name?: string } | null,
|
||||
fallback = "—",
|
||||
@@ -661,9 +650,6 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
<Text size="sm" c="dimmed" ml="auto">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@ export default function ShipmentRequestsPage() {
|
||||
{row.original.contractReference}
|
||||
</Text>
|
||||
{row.original.customerName ? (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
<Text size="xs" c="dimmed" mt={2} truncate maw={200}>
|
||||
{row.original.customerName}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function CustomersPage() {
|
||||
</Box>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={600} c="edr-text" truncate>
|
||||
<Text fw={600} c="edr-text" truncate maw={200}>
|
||||
{c.name}
|
||||
</Text>
|
||||
<CompanyNationalityBadge nationality={c.nationality} />
|
||||
@@ -156,6 +156,9 @@ export default function CustomersPage() {
|
||||
TIN {c.tin}
|
||||
{c.country ? ` · ${c.country}` : ""}
|
||||
</Text>
|
||||
<Box mt={4}>
|
||||
<CompanyStatusBadge status={c.status} />
|
||||
</Box>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
@@ -164,16 +167,9 @@ export default function CustomersPage() {
|
||||
{
|
||||
id: "profiles",
|
||||
header: "Profiles",
|
||||
cell: ({ row }) => (
|
||||
<ProfileChips profiles={row.original.companyProfiles} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
// A draft's profiles are all `pending` by construction, so the
|
||||
// "N pending" review hint would be a lie until they submit.
|
||||
// A draft's profiles are all `pending` by construction — the
|
||||
// status-colored chips would be a lie until they submit.
|
||||
if (isOnboardingDraft(row.original)) {
|
||||
return (
|
||||
<Tooltip label="Customer is still filling in the onboarding wizard">
|
||||
@@ -183,23 +179,7 @@ export default function CustomersPage() {
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const pending = (row.original.companyProfiles ?? []).filter(
|
||||
(p) => p.status === "pending",
|
||||
).length;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CompanyStatusBadge status={row.original.status} />
|
||||
{pending > 0 ? (
|
||||
<Tooltip
|
||||
label={`${pending} profile${pending > 1 ? "s" : ""} awaiting approval`}
|
||||
>
|
||||
<Badge color="yellow" variant="light" size="sm" radius="sm">
|
||||
{pending} pending
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
return <ProfileChips profiles={row.original.companyProfiles} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -229,6 +209,7 @@ export default function CustomersPage() {
|
||||
c="dimmed"
|
||||
className="inline-flex items-center gap-1"
|
||||
truncate
|
||||
maw={200}
|
||||
>
|
||||
<Mail size={12} /> {c.email}
|
||||
</Text>
|
||||
@@ -247,16 +228,6 @@ export default function CustomersPage() {
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "approved",
|
||||
header: "Approved",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
@@ -376,9 +347,6 @@ export default function CustomersPage() {
|
||||
}}
|
||||
data={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -43,17 +43,13 @@ import {
|
||||
fetchViewableFile,
|
||||
} from "@/services/files.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
const fmtDate = (iso?: string | null) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
|
||||
};
|
||||
const fmtDateTime = (iso?: string | null) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
|
||||
};
|
||||
const meta = (e: FleetHistoryEvent, k: string) => {
|
||||
const v = e.metadata?.[k];
|
||||
return typeof v === "string" && v ? v : null;
|
||||
@@ -400,7 +396,7 @@ const VehiclesTab = ({ driverId }: { driverId: string }) => {
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>{r.plate}</Table.Td>
|
||||
<Table.Td>{fmtDateTime(r.at)}</Table.Td>
|
||||
<Table.Td>{formatDateTime(r.at)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
@@ -425,7 +421,7 @@ const HistoryTab = ({ driverId }: { driverId: string }) => {
|
||||
.join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" mt={2}>{fmtDateTime(e.createdAt)}</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>{formatDateTime(e.createdAt)}</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
@@ -471,7 +467,7 @@ const TripsTab = ({ driverId }: { driverId: string }) => {
|
||||
<Table.Td>{t.booking}</Table.Td>
|
||||
<Table.Td>{t.vehicle}</Table.Td>
|
||||
<Table.Td>{t.status}</Table.Td>
|
||||
<Table.Td>{fmtDateTime(t.at)}</Table.Td>
|
||||
<Table.Td>{formatDateTime(t.at)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type GpsDevice,
|
||||
} from "@/services/gps-tracking.service";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
// Maps JavaScript API keys are public client-side keys — lock them down by
|
||||
// HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default
|
||||
@@ -51,11 +52,6 @@ const deviceLabel = (d: GpsDevice) =>
|
||||
? [d.vehicle.code, d.vehicle.plateNumber].filter(Boolean).join(" · ")
|
||||
: d.name || d.imei;
|
||||
|
||||
const fmtTime = (iso?: string | null) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
|
||||
};
|
||||
|
||||
const StatBox = ({ label, value }: { label: string; value: string }) => (
|
||||
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
|
||||
@@ -530,7 +526,7 @@ export function TrackingPage() {
|
||||
Last fix
|
||||
</Text>
|
||||
<Text fw={500} size="sm">
|
||||
{fmtTime(selected.lastFixAt)}
|
||||
{formatDateTime(selected.lastFixAt)}
|
||||
</Text>
|
||||
</div>
|
||||
{selected.vehicleId && (
|
||||
@@ -580,7 +576,7 @@ export function TrackingPage() {
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{toNum(d.lastSpeed) ?? 0} km/h ·{" "}
|
||||
{fmtTime(d.lastFixAt)}
|
||||
{formatDateTime(d.lastFixAt)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "@/services/vehicles.service";
|
||||
import { driversService } from "@/services/drivers.service";
|
||||
import { fleetHistoryService } from "@/services/fleet-history.service";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
interface MaintenanceCost {
|
||||
id: string;
|
||||
@@ -77,11 +78,6 @@ const fmtDate = (iso?: string | null) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
|
||||
};
|
||||
const fmtDateTime = (iso?: string | null) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
|
||||
};
|
||||
const money = (n?: number | null) =>
|
||||
n == null ? "—" : `ETB ${Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
|
||||
|
||||
@@ -359,7 +355,7 @@ const DriverTab = ({
|
||||
{drivers.map((d) => (
|
||||
<Table.Tr key={d.id}>
|
||||
<Table.Td>{d.name}</Table.Td>
|
||||
<Table.Td>{fmtDateTime(d.at)}</Table.Td>
|
||||
<Table.Td>{formatDateTime(d.at)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
@@ -388,7 +384,7 @@ const HistoryTab = ({ vehicleId }: { vehicleId: string }) => {
|
||||
.join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" mt={2}>{fmtDateTime(e.createdAt)}</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>{formatDateTime(e.createdAt)}</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function InvoicesPanel() {
|
||||
id: "billedTo",
|
||||
header: "Billed to",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text">
|
||||
<Text size="sm" c="edr-text" truncate maw={200}>
|
||||
{row.original.company?.name ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
@@ -170,9 +170,6 @@ export default function InvoicesPanel() {
|
||||
{ label: "Overdue", value: "OVERDUE" },
|
||||
]}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function UsdPaymentsPanel() {
|
||||
id: "billedTo",
|
||||
header: "Customer",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text">
|
||||
<Text size="sm" c="edr-text" truncate maw={200}>
|
||||
{row.original.company?.name ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
@@ -304,9 +304,6 @@ export default function UsdPaymentsPanel() {
|
||||
{ label: "Overdue", value: "OVERDUE" },
|
||||
]}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { warehouseService } from "@/services/warehouse.service";
|
||||
import type { LastMileRecord } from "@/services/last-mile.service";
|
||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
interface EdrTruckExitPapersModalProps {
|
||||
opened: boolean;
|
||||
@@ -14,8 +15,6 @@ interface EdrTruckExitPapersModalProps {
|
||||
record: LastMileRecord | null;
|
||||
}
|
||||
|
||||
const fmt = (value?: string | null) =>
|
||||
value ? new Date(value).toLocaleString() : "—";
|
||||
|
||||
/**
|
||||
* Per-truck exit papers for an EDR last-mile delivery. Each assigned truck has
|
||||
@@ -87,11 +86,11 @@ export function EdrTruckExitPapersModal({ opened, onClose, record }: EdrTruckExi
|
||||
<Text size="sm">{load}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{fmt(t.arrivedAt)}</Text>
|
||||
<Text size="sm">{formatDateTime(t.arrivedAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{t.departedAt ? (
|
||||
<Text size="sm">{fmt(t.departedAt)}</Text>
|
||||
<Text size="sm">{formatDateTime(t.departedAt)}</Text>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
Still on site
|
||||
|
||||
@@ -1139,7 +1139,11 @@ const FirstMilePage = () => {
|
||||
id: "customer",
|
||||
header: "Customer",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => customerName(row.original),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" truncate maw={200}>
|
||||
{customerName(row.original)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "postPayment",
|
||||
@@ -1189,7 +1193,7 @@ const FirstMilePage = () => {
|
||||
id: "exactKm",
|
||||
header: "Actual Distance (KM)",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
||||
cell: ({ row }) => row.original.exactKm != null ? `${row.original.exactKm} km` : <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "invoice",
|
||||
|
||||
@@ -1296,7 +1296,11 @@ const LastMilePage = () => {
|
||||
id: "customer",
|
||||
header: "Customer",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => customerName(row.original),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" truncate maw={200}>
|
||||
{customerName(row.original)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "postPayment",
|
||||
@@ -1346,7 +1350,7 @@ const LastMilePage = () => {
|
||||
id: "exactKm",
|
||||
header: "Actual Distance (KM)",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
||||
cell: ({ row }) => row.original.exactKm != null ? `${row.original.exactKm} km` : <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "invoice",
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
@@ -26,6 +25,7 @@ import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { api } from "@/services/api";
|
||||
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
|
||||
import {
|
||||
@@ -80,24 +80,6 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
refunded: "indigo",
|
||||
};
|
||||
|
||||
function formatAmount(amount: number, currency: string): string {
|
||||
return `${currency} ${Number(amount).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
const tableHeader =
|
||||
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
||||
|
||||
@@ -166,7 +148,7 @@ export default function PaymentsPanel() {
|
||||
header: () => <span className={tableHeader}>Amount</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
||||
{formatAmount(row.original.amount, row.original.currency)}
|
||||
{formatMoney(row.original.amount, row.original.currency, 2)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -327,9 +309,6 @@ export default function PaymentsPanel() {
|
||||
}}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useSetExchangeFallbackRate,
|
||||
} from "@/hooks/useExchangeSettings";
|
||||
import type { ExchangeRateSource } from "@/services/exchangeSettings.service";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
/** Feed health, phrased for an operator rather than a developer. */
|
||||
function feedLabel(source: ExchangeRateSource | null): {
|
||||
@@ -35,7 +36,7 @@ function feedLabel(source: ExchangeRateSource | null): {
|
||||
}
|
||||
|
||||
const formatTime = (value: string | null) =>
|
||||
value ? new Date(value).toLocaleString() : "never";
|
||||
value ? formatDateTime(value) : "never";
|
||||
|
||||
/**
|
||||
* USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable.
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
EmptyContainerReturn,
|
||||
EmptyContainerReturnStatus,
|
||||
} from "@/types/importOperations";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
type ReturnType = "all" | "edr" | "customer";
|
||||
|
||||
@@ -635,7 +636,7 @@ export default function ContainerReturnsPage() {
|
||||
{(historyRow.statusHistory ?? []).map((entry: any, idx: number) => (
|
||||
<Group key={idx} justify="space-between">
|
||||
<Badge size="sm">{RETURN_STATUS_LABEL[entry.status as EmptyContainerReturnStatus] ?? entry.status}</Badge>
|
||||
<Text size="sm" c="dimmed">{new Date(entry.changedAt).toLocaleString()}</Text>
|
||||
<Text size="sm" c="dimmed">{formatDateTime(entry.changedAt)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
{!(historyRow.statusHistory ?? []).length && (
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
toReleaseInventoryItem,
|
||||
} from "@/components/warehouses/options";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
@@ -79,8 +80,6 @@ const TRUCK_COLUMNS = [
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
||||
|
||||
const formatTime = (iso: string | null | undefined) =>
|
||||
iso ? new Date(iso).toLocaleString() : "—";
|
||||
|
||||
export interface BookingGroup {
|
||||
bookingId: string;
|
||||
@@ -376,10 +375,10 @@ export function TruckRows({ group }: { group: BookingGroup }) {
|
||||
<Text size="sm">{t.containers.length ? t.containers.join(", ") : "Bulk"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatTime(t.arrivedAt)}</Text>
|
||||
<Text size="xs">{formatDateTime(t.arrivedAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatTime(t.departedAt)}</Text>
|
||||
<Text size="xs">{formatDateTime(t.departedAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{formatNumber(t.weight)}</Table.Td>
|
||||
<Table.Td>{money(t.demurrage, feeCurrency)}</Table.Td>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
useInterchangeDocuments,
|
||||
} from '@/hooks/useInterchangeDocuments';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { humanize } from '@/lib/format';
|
||||
import { interchangeDocumentsService } from '@/services/interchange-documents.service';
|
||||
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
|
||||
|
||||
@@ -336,7 +337,7 @@ export default function InterchangeDocumentsPage() {
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ id: 'direction', header: 'Direction', cell: ({ row }) => row.original.direction },
|
||||
{ id: 'direction', header: 'Direction', cell: ({ row }) => humanize(row.original.direction) },
|
||||
{ id: 'train', header: 'Train No', cell: ({ row }) => row.original.trainNo ?? '-' },
|
||||
{ id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation },
|
||||
{ id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom },
|
||||
|
||||
@@ -18,6 +18,7 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useTrucksOnSite } from "@/hooks/useWarehouses";
|
||||
import type { TruckOnSite } from "@/types/warehouse";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
/**
|
||||
* Every truck inside the yard right now, across all bookings.
|
||||
@@ -122,7 +123,7 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
|
||||
</Text>
|
||||
) : isLongDwell(row.arrivedAt) ? (
|
||||
<Tooltip
|
||||
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt).toLocaleString()}`}
|
||||
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${formatDateTime(row.arrivedAt)}`}
|
||||
withArrow
|
||||
>
|
||||
<Text size="sm" c="red" fw={600}>
|
||||
|
||||
@@ -40,6 +40,7 @@ import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
import { useAuth } from '@/auth/useAuth';
|
||||
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
|
||||
import { formatMoney, humanize } from '@/lib/format';
|
||||
|
||||
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
@@ -50,7 +51,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
|
||||
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c === 'ETB' ? 'Birr (ETB)' : c}`;
|
||||
const fmt = (n: number, c: string) => formatMoney(n, c, 2);
|
||||
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
||||
|
||||
export default function WarehouseInvoicesPage() {
|
||||
@@ -79,7 +80,7 @@ export default function WarehouseInvoicesPage() {
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ id: 'type', header: 'Type', cell: ({ row }) => row.original.invoiceType.replace(/_/g, ' ') },
|
||||
{ id: 'type', header: 'Type', cell: ({ row }) => humanize(row.original.invoiceType) },
|
||||
{ id: 'total', header: 'Total', cell: ({ row }) => fmt(row.original.totalAmount, row.original.currency) },
|
||||
{ id: 'paid', header: 'Paid', cell: ({ row }) => fmt(row.original.paidAmount, row.original.currency) },
|
||||
{
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
type FeeRuleBasis,
|
||||
type FeeRuleType,
|
||||
} from '@/types/warehouse';
|
||||
import { humanize } from '@/lib/format';
|
||||
|
||||
const RULE_TYPE_COLOR: Record<FeeRuleType, string> = {
|
||||
STORAGE_FEE: 'teal',
|
||||
@@ -218,8 +219,8 @@ function AllocationRules() {
|
||||
const allocationColumns: ColumnDef<AllocationRule>[] = [
|
||||
{ id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority },
|
||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? dash },
|
||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash },
|
||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => (row.original.freightType ? humanize(row.original.freightType) : dash) },
|
||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => (row.original.tradeDirection ? humanize(row.original.tradeDirection) : dash) },
|
||||
{ id: 'cargoCode', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
|
||||
{
|
||||
id: 'targetYard',
|
||||
@@ -615,10 +616,10 @@ function FeeRules() {
|
||||
),
|
||||
},
|
||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? dash },
|
||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash },
|
||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => (row.original.freightType ? humanize(row.original.freightType) : dash) },
|
||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => (row.original.tradeDirection ? humanize(row.original.tradeDirection) : dash) },
|
||||
{ id: 'cargo', header: 'Cargo', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
|
||||
{ id: 'container', header: 'Container', cell: ({ row }) => row.original.containerType ?? dash },
|
||||
{ id: 'container', header: 'Container', cell: ({ row }) => (row.original.containerType ? humanize(row.original.containerType) : dash) },
|
||||
{
|
||||
id: 'scope',
|
||||
header: 'Location scope',
|
||||
|
||||
Reference in New Issue
Block a user